9f85908ff3
- Modernized the frontend with HTMX for server-driven UI and Alpine.js for client state. - Refactored anime, player, and recommendation logic into modular routers. - Updated README.md to reflect the latest project state and technologies (v2.4). - Added Plyr.io for an improved streaming experience. - Improved project structure with componentized templates. - Added Playwright and Vitest configuration for frontend testing.
315 lines
11 KiB
Python
315 lines
11 KiB
Python
"""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 usually structure: <div class="movie-item">...<a href="..."><img src="..."></a>...</div>
|
|
# Or directly <a> tags with images
|
|
items = soup.find_all('div', class_='movie-item')
|
|
if not items:
|
|
# Fallback to the previous method if layout is different
|
|
items = soup.find_all('a', href=re.compile(r'/s-tv/\d+-.+\.html'))
|
|
|
|
for item in items[:24]: # Limit to 24 results
|
|
# Find the link and image within the item or the item itself
|
|
if item.name == 'a':
|
|
link_elem = item
|
|
else:
|
|
link_elem = item.find('a', href=re.compile(r'/s-tv/|/films/'))
|
|
|
|
if not link_elem:
|
|
continue
|
|
|
|
url = link_elem.get('href', '')
|
|
if not url.startswith('http'):
|
|
url = urljoin(self.base_url, url)
|
|
|
|
# Extract title
|
|
img_elem = item.find('img')
|
|
title = ""
|
|
if img_elem and img_elem.get('alt'):
|
|
title = img_elem.get('alt').strip()
|
|
elif link_elem.get('title'):
|
|
title = link_elem.get('title').strip()
|
|
else:
|
|
title = item.get_text(strip=True)
|
|
|
|
# Extract cover image
|
|
img_elem = item.find('img')
|
|
cover_image = ""
|
|
if img_elem:
|
|
# Check for common lazy loading attributes used by various themes
|
|
cover_image = (
|
|
img_elem.get('data-src') or
|
|
img_elem.get('data-original') or
|
|
img_elem.get('src') or
|
|
""
|
|
)
|
|
|
|
# If still empty, look for background-style images in inline styles
|
|
if not cover_image:
|
|
style = item.get('style', '')
|
|
if 'background-image' in style:
|
|
match = re.search(r'url\([\'"]?(.*?)[\'"]?\)', style)
|
|
if match:
|
|
cover_image = match.group(1)
|
|
|
|
if cover_image and not cover_image.startswith('http'):
|
|
cover_image = urljoin(self.base_url, cover_image)
|
|
|
|
# Clean up title
|
|
title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip()
|
|
title = re.sub(r'\s+', ' ', title)
|
|
|
|
if title and len(title) > 2:
|
|
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: <div data-ep="1" data-vidzy="..." data-uqload="..." data-netu="..." data-voe="..."></div>
|
|
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}")
|