"""FS7 (French Stream) series site downloader""" import logging import re from typing import List, Dict, Any, Optional from urllib.parse import urljoin, urlparse from bs4 import BeautifulSoup from app.utils import sanitize_filename from .base import BaseSeriesSite logger = logging.getLogger(__name__) class FS7Downloader(BaseSeriesSite): """ Downloader for FS7 (French Stream) series site. FS7 is a French streaming site for TV series and films. """ def __init__(self): super().__init__() self.base_url = "https://fs7.lol" self.search_url = f"{self.base_url}/" # Update client headers to mimic browser self.client.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', 'Accept-Encoding': 'gzip, deflate', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1' }) def can_handle(self, url: str) -> bool: """Check if this downloader can handle the given URL""" return "fs7.lol" in url.lower() or "french-stream" in url.lower() async def search_anime( self, query: str, lang: str = "vf" ) -> List[Dict[str, str]]: """ Search for series on FS7. Args: query: Search query lang: Language preference (vf, vostfr) Returns: List of series with title, url, cover_image """ try: logger.info(f"Searching FS7 for: {query}") # FS7 uses GET request with query parameters for search response = await self.client.get( self.search_url, params={ "do": "search", "subaction": "search", "story": query } ) response.raise_for_status() html = response.text soup = BeautifulSoup(html, 'lxml') results = [] # Look for series items (FS7 has both films and series in search results) # We filter for /s-tv/ URLs ending with .html (actual series/season pages) items = soup.find_all('a', href=re.compile(r'/s-tv/\d+-.+\.html')) for item in items[:20]: # Limit to 20 results url = item.get('href', '') if not url.startswith('http'): url = urljoin(self.base_url, url) # Extract title from the item title_elem = item.find('img', alt=True) if title_elem: title = title_elem.get('alt', '').strip() else: # Get text content and clean it text = item.get_text(strip=True) # Skip if it's just a category name if any(cat in text.lower() for cat in ['séries', 'series', 'vf', 'vostfr', 'vo', 'netflix', 'disney', 'amazon', 'apple']): continue title = text # Clean up title: remove "affiche" suffix and clean extra whitespace title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip() title = re.sub(r'\s+', ' ', title) # Normalize whitespace # Extract cover image img = item.find('img') cover_image = img.get('src', '') if img else '' # Only add if we have a title and it's not empty if title and len(title) > 5: # Avoid duplicates if not any(r['url'] == url for r in results): results.append({ 'title': title, 'url': url, 'cover_image': cover_image }) logger.info(f"Found {len(results)} series on FS7") return results except Exception as e: logger.error(f"Error searching FS7: {e}") return [] async def get_episodes( self, anime_url: str, lang: str = "vf" ) -> List[Dict[str, str]]: """ Get episode list for a series. Args: anime_url: URL of the series page lang: Language preference Returns: List of episodes with episode number and url """ try: logger.info(f"Fetching episodes from: {anime_url}") response = await self.client.get(anime_url) response.raise_for_status() html = response.text soup = BeautifulSoup(html, 'lxml') episodes = [] # Get series title for episode naming title_elem = soup.find('h1') series_title = title_elem.get_text(strip=True) if title_elem else "Series" # Clean up title: remove "affiche" suffix series_title = re.sub(r'\s+affiche$', '', series_title, flags=re.IGNORECASE).strip() # FS7 stores episode data in JavaScript div elements # Format:
episode_divs = soup.find_all('div', attrs={'data-ep': True}) for div in episode_divs: ep_num = div.get('data-ep', '').strip() # Try different video players in order of preference video_url = None host_name = None for player in ['data-vidzy', 'data-uqload', 'data-voe', 'data-netu']: player_url = div.get(player, '').strip() if player_url: video_url = player_url # Extract host name from attribute name host_name = player.replace('data-', '').title() logger.debug(f"Found episode {ep_num} on {host_name}") break if video_url and ep_num: # Create episode title for filename episode_title = f"{series_title} - Episode {ep_num}" # Use pipe-separated format: video_url|anime_url|episode_title combined_url = f"{video_url}|{anime_url}|{episode_title}" episodes.append({ 'episode': ep_num, 'url': combined_url, 'title': episode_title, 'host': host_name or 'Unknown' }) # Sort by episode number episodes.sort(key=lambda x: int(x['episode']) if x['episode'].isdigit() else 0) logger.info(f"Found {len(episodes)} episodes") return episodes except Exception as e: logger.error(f"Error getting episodes from FS7: {e}") return [] async def get_anime_metadata( self, anime_url: str ) -> Dict[str, Any]: """ Get metadata for a series. Args: anime_url: URL of the series page Returns: Dictionary with metadata """ try: logger.info(f"Fetching metadata from: {anime_url}") response = await self.client.get(anime_url) response.raise_for_status() html = response.text soup = BeautifulSoup(html, 'lxml') # Extract title title = soup.find('h1') title = title.get_text(strip=True) if title else "Unknown" # Clean up title: remove "affiche" suffix title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip() # Extract description/synopsis description_elem = soup.find('div', class_='full-text') description = description_elem.get_text(strip=True) if description_elem else "" # Extract cover image img = soup.find('img', class_='poster') poster_image = img.get('src', '') if img else '' # Try to get poster from meta tag if not found if not poster_image: meta_img = soup.find('meta', property='og:image') poster_image = meta_img.get('content', '') if meta_img else '' # Extract year year_match = re.search(r'\b(19|20)\d{2}\b', description) release_year = int(year_match.group()) if year_match else None return { 'title': title, 'synopsis': description, 'poster_image': poster_image, 'release_year': release_year, 'genres': [], 'rating': None, 'studio': None, 'total_episodes': None, 'status': None } except Exception as e: logger.error(f"Error getting metadata from FS7: {e}") return { 'title': "Unknown", 'synopsis': "", 'poster_image': '', 'genres': [], 'rating': None, 'release_year': None, 'studio': None, 'total_episodes': None, 'status': None } async def get_download_link( self, url: str, target_filename: Optional[str] = None ) -> tuple[str, str]: """ Extract download link from video player URL. Args: url: Video player URL target_filename: Optional filename override Returns: Tuple of (download_url, filename) """ # FS7 uses embedded video players # Delegate to the appropriate video player downloader from app.downloaders.video_players import get_video_player player = get_video_player(url) if player: return await player.get_download_link(url, target_filename) else: raise ValueError(f"No video player found for URL: {url}")