docs: Update CLAUDE.md with three-tier architecture and new providers

- Added new video players: Vidzy, LuLuvid, Uqload
- Added new anime site: French-Manga
- Added new series sites category with FS7
- Updated documentation to reflect three-tier architecture (anime sites → series sites → video players)
- Added BaseSeriesSite interface documentation
- Added "Adding New Series Site" section
- Updated test organization with test_french_manga.py

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
root
2026-01-25 10:34:39 +00:00
parent 3afad41d46
commit 4d280b5239
16 changed files with 1507 additions and 53 deletions
+117 -42
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Ohm Stream Downloader is a FastAPI-based web application for downloading anime episodes and media files from various file hosting services (1fichier, Doodstream, Rapidfile, Uptobox, VidMoly, SendVid, Sibnet, Lpayer) and anime streaming platforms (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree). It features a modern web interface, parallel downloads, pause/resume support, video streaming, personalized recommendations, and Sonarr webhook integration for automated downloads.
Ohm Stream Downloader is a FastAPI-based web application for downloading anime episodes and media files from various file hosting services (1fichier, Doodstream, Rapidfile, Uptobox, VidMoly, SendVid, Sibnet, Lpayer, Vidzy, LuLuvid, Uqload) and streaming platforms (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga, FS7). It features a modern web interface, parallel downloads, pause/resume support, video streaming, personalized recommendations, and Sonarr webhook integration for automated downloads.
## Development Commands
@@ -52,22 +52,35 @@ Ohm_streaming/
├── main.py # FastAPI application & API endpoints
├── app/
│ ├── models/ # Pydantic models (DownloadTask, AnimeMetadata, Sonarr, etc.)
│ ├── downloaders/ # Host-specific downloaders
│ │ ├── base.py # BaseDownloader abstract class
│ │ ├── unfichier.py # 1fichier.com handler
│ │ ├── doodstream.py # Doodstream handler
│ │ ├── rapidfile.py # Rapidfile handler
│ │ ├── uptobox.py # Uptobox handler
│ │ ├── vidmoly.py # VidMoly handler
│ │ ├── sendvid.py # SendVid handler
│ │ ├── sibnet.py # Sibnet handler
│ │ ├── lpayer.py # Lpayer handler
│ │ ├── vidmoly_old.py # Old VidMoly implementation (deprecated)
│ │ ├── animesama.py # Anime-Sama (anime provider)
│ │ ├── animeultime.py # Anime-Ultime (anime provider)
│ │ ├── nekosama.py # Neko-Sama (anime provider)
│ │ ├── vostfree.py # Vostfree (anime provider)
│ │ └── __init__.py # Factory function and registry
│ ├── downloaders/ # Host-specific downloaders (organized structure)
│ │ ├── base.py # BaseDownloader abstract class (legacy, kept for compatibility)
│ │ ├── __init__.py # Factory function (three-tier: anime sites → series sites → video players)
│ │ ├── anime_sites/ # Anime streaming sites (catalogs)
│ │ │ ├── base.py # BaseAnimeSite abstract class
│ │ │ ├── __init__.py # Anime site factory
│ │ │ ├── animesama.py # Anime-Sama (anime provider)
│ │ │ ├── animeultime.py # Anime-Ultime (anime provider)
│ │ │ ├── nekosama.py # Neko-Sama (anime provider)
│ │ │ ├── vostfree.py # Vostfree (anime provider)
│ │ │ └── frenchmanga.py # French-Manga (anime provider)
│ │ ├── series_sites/ # TV series streaming sites (catalogs)
│ │ │ ├── base.py # BaseSeriesSite abstract class
│ │ │ ├── __init__.py # Series site factory
│ │ │ └── fs7.py # FS7 (French Stream)
│ │ └── video_players/ # File hosting services (players)
│ │ ├── base.py # BaseVideoPlayer abstract class
│ │ ├── __init__.py # Video player factory
│ │ ├── unfichier.py # 1fichier.com handler
│ │ ├── doodstream.py # Doodstream handler
│ │ ├── rapidfile.py # Rapidfile handler
│ │ ├── uptobox.py # Uptobox handler
│ │ ├── vidmoly.py # VidMoly handler
│ │ ├── sendvid.py # SendVid handler
│ │ ├── sibnet.py # Sibnet handler
│ │ ├── lpayer.py # Lpayer handler
│ │ ├── vidzy.py # Vidzy handler
│ │ ├── luluv.py # LuLuvid handler
│ │ └── uqload.py # Uqload handler
│ ├── providers.py # Provider configuration (domains, icons, colors)
│ ├── config.py # Environment-based configuration (Pydantic Settings)
│ ├── utils.py # Security utilities (sanitize_filename, is_safe_filename)
@@ -105,24 +118,61 @@ Ohm_streaming/
### 2. Downloaders (`app/downloaders/`)
**Factory Pattern:**
- `get_downloader(url)` in `__init__.py` selects appropriate downloader
- Each downloader inherits from `BaseDownloader` abstract class
- Order matters: anime providers checked first, then file hosts
- Falls back to `GenericDownloader` if no match
**Architecture:**
The downloaders are organized into three categories with separate base classes:
**BaseDownloader Interface:**
- `can_handle(url)` - Check if downloader supports the URL
- `get_download_link(url)` - Extract direct download link and filename
- `search_anime(query, lang)` - Search anime (anime providers only)
- `get_episodes(anime_url, lang)` - Get episode list (anime providers only)
- `get_anime_metadata(anime_url)` - Get metadata dict (anime providers only) - Note: not in base.py, implemented by anime providers
**Anime Sites** (`app/downloaders/anime_sites/`):
- Provide anime catalogs, metadata, and episode listings
- Link to video players for actual file hosting
- Inherit from `BaseAnimeSite` abstract class
- Factory: `get_anime_site(url)` in `anime_sites/__init__.py`
- Implement: `search_anime()`, `get_episodes()`, `get_anime_metadata()`, `get_download_link()`
**Series Sites** (`app/downloaders/series_sites/`):
- Provide TV series catalogs, metadata, and episode listings
- Similar to anime sites but for general TV series content
- Inherit from `BaseSeriesSite` abstract class
- Factory: `get_series_site(url)` in `series_sites/__init__.py`
- Implement: `search_anime()`, `get_episodes()`, `get_anime_metadata()`, `get_download_link()`
**Video Players** (`app/downloaders/video_players/`):
- Host actual video files and provide direct download links
- Extract URLs from embedded players and handle file downloads
- Inherit from `BaseVideoPlayer` abstract class
- Factory: `get_video_player(url)` in `video_players/__init__.py`
- Implement: `get_download_link(url, target_filename=None)`
**Three-Tier Factory Pattern:**
- `get_downloader(url)` in main `__init__.py` checks: anime sites → series sites → video players
- Falls back to `GenericDownloader` if no match
- This separation allows anime/series sites to delegate to video players for actual downloads
**BaseAnimeSite Interface:**
- `can_handle(url)` - Check if this anime site can handle the URL
- `search_anime(query, lang)` - Search for anime, returns list with title, url, cover_image
- `get_episodes(anime_url, lang)` - Get episode list with episode_number, url, title, host
- `get_anime_metadata(anime_url)` - Get metadata dict (synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status)
- `get_download_link(url)` - Get video player URL from episode page (NOT direct download link)
**BaseSeriesSite Interface:**
- `can_handle(url)` - Check if this series site can handle the URL
- `search_anime(query, lang)` - Search for series, returns list with title, url, cover_image, lang
- `get_episodes(anime_url, lang)` - Get episode list with episode_number, url, title, host
- `get_anime_metadata(anime_url)` - Get metadata dict (title, synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status, languages)
- `get_download_link(url)` - Get video player URL from episode page (NOT direct download link)
**BaseVideoPlayer Interface:**
- `can_handle(url)` - Check if this player can handle the URL
- `get_download_link(url, target_filename=None)` - Extract direct download link and filename
- Note: `target_filename` parameter is optional but MUST be supported for VidMoly/SendVid compatibility
- Always use `sanitize_filename()` on extracted filenames!
**Key Patterns:**
- All downloaders use httpx.AsyncClient for HTTP requests
- BeautifulSoup with lxml for HTML parsing
- Async/await throughout for non-blocking I/O
- Fuzzy search using jieba for Chinese text segmentation and typo tolerance
- Security: Filename sanitization enforced via `app.utils` functions
### 3. Provider Configuration (`app/providers.py`)
- `ANIME_PROVIDERS` - Anime streaming sites configuration
@@ -228,6 +278,7 @@ Ohm_streaming/
- `test_anime_sama_seasons.py` - Anime-Sama season handling tests
- `test_translate_api.py` - Translation API tests
- `test_delete_and_restore.py` - Delete and restore functionality tests
- `test_french_manga.py` - French-Manga provider tests
**Fixtures in conftest.py:**
- `temp_dir` - Temporary directory
@@ -269,28 +320,31 @@ pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
To add support for a new file hosting service:
1. Create new file in `app/downloaders/` (e.g., `myhost.py`)
2. Inherit from `BaseDownloader`
3. Implement required methods
4. Add to imports in `app/downloaders/__init__.py`
5. Add to `downloaders` list in `get_downloader()`
1. Create new file in `app/downloaders/video_players/` (e.g., `myhost.py`)
2. Inherit from `BaseVideoPlayer`
3. Implement required methods (`can_handle`, `get_download_link`)
4. Add to imports in `app/downloaders/video_players/__init__.py`
5. Add to `players` list in `get_video_player()`
6. Add configuration to `FILE_HOSTS` in `app/providers.py`
Example:
```python
from .base import BaseDownloader
from .base import BaseVideoPlayer
from bs4 import BeautifulSoup
class MyHostDownloader(BaseDownloader):
class MyHostDownloader(BaseVideoPlayer):
def can_handle(self, url: str) -> bool:
return "myhost.com" in url.lower()
async def get_download_link(self, url: str) -> tuple[str, str]:
async def get_download_link(self, url: str, target_filename: Optional[str] = None) -> tuple[str, str]:
soup = BeautifulSoup(await self._fetch_page(url), 'lxml')
# ... extraction logic ...
# IMPORTANT: Always sanitize filenames!
from app.utils import sanitize_filename
filename = sanitize_filename(extracted_filename)
return download_url, filename
def close(self):
async def close(self):
# IMPORTANT: Always close the HTTP client
await self.client.aclose()
```
@@ -299,6 +353,23 @@ class MyHostDownloader(BaseDownloader):
- Always close the HTTP client in your downloader to avoid resource leaks
- Use `sanitize_filename()` from `app.utils` when extracting filenames from URLs
- Use `is_safe_filename()` to validate filenames before file operations
- The `target_filename` parameter is required for compatibility with anime/series sites
## Adding New Series Site
To add a new TV series streaming provider (similar to anime sites but for general TV series):
1. Create new file in `app/downloaders/series_sites/` (e.g., `mysite.py`)
2. Inherit from `BaseSeriesSite`
3. Implement series-specific methods:
- `search_anime(query, lang)` - Return list of series with title, url, cover_image, lang
- `get_episodes(anime_url, lang)` - Return list of episodes
- `get_anime_metadata(anime_url)` - Return metadata dict (should include languages field)
- `get_download_link(url)` - Return video player URL from episode page
4. Add to imports in `app/downloaders/series_sites/__init__.py`
5. Add to `sites` list in `get_series_site()`
BaseSeriesSite is nearly identical to BaseAnimeSite but designed for general TV series content rather than anime-specific content.
## Sonarr Integration
@@ -376,14 +447,17 @@ The application includes full Sonarr webhook support for automated anime downloa
To add a new anime streaming provider:
1. Create downloader inheriting from `BaseDownloader`
2. Implement anime-specific methods:
1. Create new file in `app/downloaders/anime_sites/` (e.g., `mysite.py`)
2. Inherit from `BaseAnimeSite`
3. Implement anime-specific methods:
- `search_anime(query, lang)` - Return list of anime with title, url, cover_image
- `get_episodes(anime_url, lang)` - Return list of episodes
- `get_anime_metadata(anime_url)` - Return metadata dict
3. Add to `ANIME_PROVIDERS` in `app/providers.py`
4. Add to factory in `app/downloaders/__init__.py`
5. Update `main.py` to include in unified search
- `get_download_link(url)` - Return video player URL from episode page
4. Add to imports in `app/downloaders/anime_sites/__init__.py`
5. Add to `sites` list in `get_anime_site()`
6. Add to `ANIME_PROVIDERS` in `app/providers.py`
7. Update `main.py` to include in unified search
Metadata should include:
- synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status
@@ -423,6 +497,7 @@ LOG_LEVEL=INFO # Logging level
- `CLAUDE.md` - This file (developer guide)
- `docs/SONARR_INTEGRATION.md` - Complete Sonarr setup guide
- `docs/SONARR_IMPLEMENTATION.md` - Technical implementation summary
- `docs/IMPROVEMENTS_2024-01-24.md` - Recent security and quality improvements
## Key Implementation Details
+11
View File
@@ -21,6 +21,11 @@ from .anime_sites import (
AnimeUltimeDownloader,
VostfreeDownloader
)
from .series_sites import (
BaseSeriesSite,
get_series_site,
FS7Downloader
)
def get_downloader(url: str) -> BaseDownloader:
@@ -29,6 +34,7 @@ def get_downloader(url: str) -> BaseDownloader:
This function now uses the organized structure:
- Checks anime sites first (for catalogs/search)
- Then checks series sites (for catalogs/search)
- Then checks video players (for direct download links)
- Falls back to generic downloader if no match
"""
@@ -37,6 +43,11 @@ def get_downloader(url: str) -> BaseDownloader:
if anime_site:
return anime_site
# Then try series sites
series_site = get_series_site(url)
if series_site:
return series_site
# Then try video players
video_player = get_video_player(url)
if video_player:
+3
View File
@@ -5,6 +5,7 @@ from .animesama import AnimeSamaDownloader
from .nekosama import NekoSamaDownloader
from .animeultime import AnimeUltimeDownloader
from .vostfree import VostfreeDownloader
from .frenchmanga import FrenchMangaDownloader
__all__ = [
"BaseAnimeSite",
@@ -12,6 +13,7 @@ __all__ = [
"NekoSamaDownloader",
"AnimeUltimeDownloader",
"VostfreeDownloader",
"FrenchMangaDownloader",
]
@@ -22,6 +24,7 @@ def get_anime_site(url: str) -> BaseAnimeSite:
AnimeUltimeDownloader(),
NekoSamaDownloader(),
VostfreeDownloader(),
FrenchMangaDownloader(),
]
for site in sites:
+299
View File
@@ -0,0 +1,299 @@
"""French-Manga.net anime streaming site downloader"""
from .base import BaseAnimeSite
from bs4 import BeautifulSoup
import re
from typing import List, Dict, Any
from app.utils import sanitize_filename
import logging
logger = logging.getLogger(__name__)
class FrenchMangaDownloader(BaseAnimeSite):
"""Downloader for french-manga.net anime streaming site"""
# Known domains for French-Manga
BASE_DOMAINS = [
"french-manga.net",
"w16.french-manga.net",
"w15.french-manga.net",
"www.french-manga.net"
]
def __init__(self):
super().__init__()
self.base_url = "https://w16.french-manga.net"
def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL"""
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def search_anime(
self,
query: str,
lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Search for anime on French-Manga.
Args:
query: Search query (anime title)
lang: Language preference (vostfr, vf)
Returns:
List of anime with title, url, cover_image
"""
try:
# French-Manga uses a search endpoint
search_url = f"{self.base_url}/index.php?do=search"
params = {
'do': 'search',
'subaction': 'search',
'story': query,
'x': '0',
'y': '0'
}
response = await self.client.post(search_url, data=params)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
results = []
# Look for search results in article or story classes
for item in soup.find_all('article', class_=lambda x: x and 'story' in x.lower()):
title_elem = item.find(['h2', 'h3', 'h4'])
link_elem = item.find('a', href=True)
img_elem = item.find('img')
if title_elem and link_elem:
title = title_elem.get_text(strip=True)
url = link_elem['href']
# Ensure absolute URL
if url.startswith('/'):
url = self.base_url + url
cover_image = ""
if img_elem and img_elem.get('src'):
cover_image = img_elem['src']
if cover_image.startswith('/'):
cover_image = self.base_url + cover_image
results.append({
'title': title,
'url': url,
'cover_image': cover_image,
'lang': lang
})
logger.info(f"Found {len(results)} anime results for query: {query}")
return results
except Exception as e:
logger.error(f"Error searching anime: {e}")
return []
async def get_episodes(
self,
anime_url: str,
lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Get episode list for an anime.
Args:
anime_url: URL of the anime page
lang: Language preference
Returns:
List of episodes with episode_number, url, title
"""
try:
response = await self.client.get(anime_url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
episodes = []
# Look for episode links (typically in a list or table)
# French-Manga usually has episode links in <a> tags with episode numbers
for link in soup.find_all('a', href=True):
href = link['href']
text = link.get_text(strip=True)
# Pattern: Episode links usually contain "episode" or numbers
if re.search(r'episode?\s*\d+', text.lower()):
episode_num = re.search(r'(\d+)', text)
if episode_num:
episode_number = int(episode_num.group(1))
# Ensure absolute URL
if href.startswith('/'):
href = self.base_url + href
episodes.append({
'episode_number': episode_number,
'url': href,
'title': text,
'host': 'french-manga'
})
# Sort by episode number
episodes.sort(key=lambda x: x['episode_number'])
logger.info(f"Found {len(episodes)} episodes for {anime_url}")
return episodes
except Exception as e:
logger.error(f"Error getting episodes: {e}")
return []
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
"""
Get detailed metadata for an anime.
Args:
anime_url: URL of the anime page
Returns:
Dict with metadata (synopsis, genres, rating, etc.)
"""
try:
response = await self.client.get(anime_url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
# Extract title
title = ""
title_elem = soup.find('h1') or soup.find('h2', class_='title')
if title_elem:
title = title_elem.get_text(strip=True)
# Extract synopsis
synopsis = ""
synopsis_elem = soup.find('div', class_=lambda x: x and 'story' in x.lower())
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
# Extract cover image
poster_image = ""
img_elem = soup.find('img', class_=lambda x: x and 'poster' in x.lower())
if img_elem and img_elem.get('src'):
poster_image = img_elem['src']
if poster_image.startswith('/'):
poster_image = self.base_url + poster_image
# Extract genres
genres = []
genre_links = soup.find_all('a', href=re.compile(r'/xfsearch/.*genre/'))
for link in genre_links[:10]: # Limit to 10 genres
genre = link.get_text(strip=True)
if genre:
genres.append(genre)
# Extract rating (if available)
rating = ""
rating_elem = soup.find(['span', 'div'], class_=lambda x: x and 'rating' in x.lower())
if rating_elem:
rating = rating_elem.get_text(strip=True)
return {
'title': title,
'synopsis': synopsis,
'genres': genres,
'rating': rating,
'release_year': '',
'studio': '',
'poster_image': poster_image,
'total_episodes': len(await self.get_episodes(anime_url)),
'status': '',
'languages': ['vf', 'vostfr']
}
except Exception as e:
logger.error(f"Error getting anime metadata: {e}")
return {
'title': '',
'synopsis': '',
'genres': [],
'rating': '',
'release_year': '',
'studio': '',
'poster_image': '',
'total_episodes': 0,
'status': '',
'languages': ['vf', 'vostfr']
}
async def get_download_link(self, url: str) -> tuple[str, str]:
"""
Get download link from episode page.
For French-Manga, this returns the video player URL.
The actual video extraction will be handled by the video player downloaders.
Args:
url: Episode page URL
Returns:
Tuple of (video_player_url, episode_title)
"""
try:
response = await self.client.get(url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
# Look for iframe or video player
iframe = soup.find('iframe', src=True)
if iframe:
video_url = iframe['src']
else:
# Look for video tag directly
video = soup.find('video', src=True)
if video:
video_url = video['src']
else:
# Try to find in script tags
scripts = soup.find_all('script')
for script in scripts:
if script.string:
# Look for iframe or video URLs in JavaScript
patterns = [
r'iframe.*?src=["\']([^"\']+)["\']',
r'video.*?src=["\']([^"\']+)["\']',
]
for pattern in patterns:
match = re.search(pattern, script.string, re.IGNORECASE)
if match:
video_url = match.group(1)
break
if 'video_url' in locals():
break
if 'video_url' not in locals():
raise ValueError("Could not find video player URL")
# Ensure absolute URL
if video_url.startswith('//'):
video_url = 'https:' + video_url
elif video_url.startswith('/'):
video_url = self.base_url + video_url
# Extract episode title
title_elem = soup.find('h1') or soup.find('h2')
episode_title = title_elem.get_text(strip=True) if title_elem else "Episode"
episode_title = sanitize_filename(episode_title)
logger.info(f"Extracted video player URL: {video_url[:60]}...")
return video_url, episode_title
except Exception as e:
logger.error(f"Error getting download link: {e}")
raise ValueError(f"Failed to extract download link: {str(e)}")
+23
View File
@@ -0,0 +1,23 @@
"""Series streaming sites (catalogs) downloaders"""
from .base import BaseSeriesSite
# Import all series site downloaders
from .fs7 import FS7Downloader
__all__ = [
"BaseSeriesSite",
"FS7Downloader",
]
def get_series_site(url: str) -> BaseSeriesSite:
"""Factory function to get the appropriate series site for a URL"""
sites = [
FS7Downloader(),
]
for site in sites:
if site.can_handle(url):
return site
# Return None if no match (should not happen in normal flow)
return None
+131
View File
@@ -0,0 +1,131 @@
"""Base class for series streaming sites (catalogs)"""
from abc import abstractmethod
from typing import List, Dict, Any, Optional, Tuple
import logging
import httpx
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
class BaseSeriesSite:
"""
Base class for series streaming sites.
Series sites provide catalogs, metadata, and episode listings.
They typically link to video players for actual file hosting.
Examples: FS7 (French Stream), etc.
KEY FEATURE: Provides rich metadata and episode management for TV series
"""
def __init__(self):
# Initialize HTTP client directly
self.client = httpx.AsyncClient(timeout=10.0, follow_redirects=True)
@abstractmethod
def can_handle(self, url: str) -> bool:
"""Check if this series site can handle the given URL"""
pass
@abstractmethod
async def search_anime(
self,
query: str,
lang: str = "vf"
) -> List[Dict[str, str]]:
"""
Search for series on this site.
Args:
query: Search query (series title)
lang: Language preference (vf, vostfr)
Returns:
List of series with keys:
- title: Series title
- url: Series page URL
- cover_image: Optional cover image URL
- lang: Available languages
"""
pass
@abstractmethod
async def get_episodes(
self,
anime_url: str,
lang: str = "vf"
) -> List[Dict[str, str]]:
"""
Get list of episodes for a series.
Args:
anime_url: URL of the series page
lang: Language preference
Returns:
List of episodes with keys:
- episode_number: Episode number
- url: Episode page URL
- title: Optional episode title
- host: Video player hosting the file
"""
pass
@abstractmethod
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
"""
Get detailed metadata for a series.
Args:
anime_url: URL of the series page
Returns:
Dict with metadata:
- title: Series title
- synopsis: Plot summary
- genres: List of genres
- rating: Rating (e.g., "8.5/10")
- release_year: Release year
- studio: Production studio
- poster_image: Poster URL
- total_episodes: Total episode count
- status: Airing status (ongoing, completed)
- languages: Available languages
"""
pass
@abstractmethod
async def get_download_link(self, url: str) -> Tuple[str, str]:
"""
Get download link for a specific episode.
For series sites, this extracts the video player URL from an episode page.
Note: Returns video player URL, NOT direct download link!
Returns:
Tuple of (video_player_url, episode_title)
"""
pass
# Common methods for all series sites
async def close(self):
"""Close HTTP client"""
await self.client.aclose()
async def _fetch_page(self, url: str) -> str:
"""Fetch HTML page content"""
response = await self.client.get(url)
response.raise_for_status()
return response.text
def _parse_html(self, html: str) -> BeautifulSoup:
"""Parse HTML with BeautifulSoup"""
return BeautifulSoup(html, 'lxml')
def _extract_season_number(self, title: str) -> Optional[int]:
"""Extract season number from title (e.g., 'Saison 2' -> 2)"""
import re
match = re.search(r'saison\s*(\d+)', title.lower())
return int(match.group(1)) if match else None
+262
View File
@@ -0,0 +1,262 @@
"""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
# 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 = []
# 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
for player in ['data-vidzy', 'data-uqload', 'data-voe', 'data-netu']:
player_url = div.get(player, '').strip()
if player_url:
video_url = player_url
logger.debug(f"Found episode {ep_num} on {player}")
break
if video_url and ep_num:
episodes.append({
'episode': ep_num,
'url': video_url
})
# 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"
# 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}")
@@ -9,6 +9,9 @@ from .lpayer import LpayerDownloader
from .unfichier import UnFichierDownloader
from .uptobox import UptoboxDownloader
from .rapidfile import RapidFileDownloader
from .vidzy import VidzyDownloader
from .luluv import LuLuvidDownloader
from .uqload import UqloadDownloader
__all__ = [
"BaseVideoPlayer",
@@ -20,6 +23,9 @@ __all__ = [
"UnFichierDownloader",
"UptoboxDownloader",
"RapidFileDownloader",
"VidzyDownloader",
"LuLuvidDownloader",
"UqloadDownloader",
]
@@ -34,6 +40,9 @@ def get_video_player(url: str) -> BaseVideoPlayer:
UnFichierDownloader(),
UptoboxDownloader(),
RapidFileDownloader(),
VidzyDownloader(),
LuLuvidDownloader(),
UqloadDownloader(),
]
for player in players:
+112
View File
@@ -0,0 +1,112 @@
"""LuLuvid video hosting service downloader"""
import logging
from typing import Optional
from .base import BaseVideoPlayer
from bs4 import BeautifulSoup
from app.utils import sanitize_filename
logger = logging.getLogger(__name__)
class LuLuvidDownloader(BaseVideoPlayer):
"""
Downloader for LuLuvid video hosting service.
LuLuvid is a video hosting platform used by various anime streaming sites.
"""
def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL"""
return "luluv" in url.lower() or "luluvid" in url.lower()
async def get_download_link(
self,
url: str,
target_filename: Optional[str] = None
) -> tuple[str, str]:
"""
Extract direct download link and filename from LuLuvid URL.
Args:
url: The LuLuvid video player URL
target_filename: Optional filename override
Returns:
Tuple of (download_url, filename)
"""
try:
logger.info(f"Fetching LuLuvid URL: {url}")
# Fetch the page
response = await self.client.get(url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
# Method 1: Look for video source in <video> tag
video_tag = soup.find('video')
if video_tag and video_tag.get('src'):
download_url = video_tag['src']
logger.info(f"Found video source from <video> tag")
else:
# Method 2: Look for source in <source> tag
source_tag = soup.find('source')
if source_tag and source_tag.get('src'):
download_url = source_tag['src']
logger.info(f"Found video source from <source> tag")
else:
# Method 3: Look for video URL in JavaScript
# LuLuvid often stores the video URL in a JavaScript variable
scripts = soup.find_all('script')
for script in scripts:
if script.string:
# Look for patterns like 'file:"URL"' or 'source:"URL"'
import re
patterns = [
r'file\s*:\s*["\']([^"\']+\.mp4[^"\']*)["\']',
r'source\s*:\s*["\']([^"\']+\.mp4[^"\']*)["\']',
r'videoUrl\s*:\s*["\']([^"\']+)["\']',
r'"url"\s*:\s*["\']([^"\']+\.mp4[^"\']*)["\']',
r'["\']src["\']\s*:\s*["\']([^"\']+\.mp4[^"\']*)["\']',
]
for pattern in patterns:
match = re.search(pattern, script.string)
if match:
download_url = match.group(1)
logger.info(f"Found video source from JavaScript")
break
if 'download_url' in locals():
break
if 'download_url' not in locals():
raise ValueError("Could not find video URL in page")
# Ensure URL is absolute
if not download_url.startswith('http'):
if download_url.startswith('//'):
download_url = 'https:' + download_url
else:
from urllib.parse import urljoin
download_url = urljoin(url, download_url)
# Generate filename
if target_filename:
filename = sanitize_filename(target_filename)
else:
# Try to extract filename from URL
filename = download_url.split('/')[-1].split('?')[0]
if not filename or len(filename) < 5:
filename = "luluv_video.mp4"
filename = sanitize_filename(filename)
# Ensure .mp4 extension
if not filename.endswith('.mp4'):
filename += '.mp4'
logger.info(f"Successfully extracted LuLuvid download link: {filename}")
return download_url, filename
except Exception as e:
logger.error(f"Error extracting LuLuvid download link: {e}")
raise ValueError(f"Failed to extract download link from LuLuvid: {str(e)}")
+110
View File
@@ -0,0 +1,110 @@
"""Uqload video hosting service downloader"""
import logging
import re
from typing import Optional
from .base import BaseVideoPlayer
from bs4 import BeautifulSoup
from app.utils import sanitize_filename
logger = logging.getLogger(__name__)
class UqloadDownloader(BaseVideoPlayer):
"""
Downloader for Uqload video hosting service.
Uqload is a video hosting platform used by French Stream and other streaming sites.
"""
def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL"""
return "uqload" in url.lower()
async def get_download_link(
self,
url: str,
target_filename: Optional[str] = None
) -> tuple[str, str]:
"""
Extract direct download link and filename from Uqload URL.
Args:
url: The Uqload video player URL
target_filename: Optional filename override
Returns:
Tuple of (download_url, filename)
"""
try:
logger.info(f"Fetching Uqload URL: {url}")
# Fetch the page
response = await self.client.get(url)
response.raise_for_status()
html = response.text
# Method 1: Look for video URL in JavaScript
# Uqload stores the video URL in a JavaScript variable like: sources: ["URL"]
patterns = [
r'sources:\s*\["([^"]+\.mp4[^"]*)"\]',
r'sources:\s*\[["\']([^"\']+\.mp4[^"\']*)["\']\]',
r'"sources":\s*\["([^"]+\.mp4[^"]*)"\]',
r'file:\s*"([^"]+\.mp4[^"]*)"',
r'file:\s*["\']([^"\']+\.mp4[^"\']*)["\']',
r'"file"\s*:\s*"([^"]+\.mp4[^"]*)"',
]
for pattern in patterns:
match = re.search(pattern, html)
if match:
download_url = match.group(1)
# Clean up any escape characters
download_url = download_url.replace('\\/', '/')
logger.info(f"Found video source from JavaScript pattern: {pattern[:20]}...")
break
else:
# Method 2: Try parsing with BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
# Look for video tag
video_tag = soup.find('video')
if video_tag and video_tag.get('src'):
download_url = video_tag['src']
logger.info(f"Found video source from <video> tag")
else:
# Look for source tag
source_tag = soup.find('source')
if source_tag and source_tag.get('src'):
download_url = source_tag['src']
logger.info(f"Found video source from <source> tag")
else:
raise ValueError("Could not find video URL in Uqload page")
# Ensure URL is absolute
if not download_url.startswith('http'):
if download_url.startswith('//'):
download_url = 'https:' + download_url
else:
from urllib.parse import urljoin
download_url = urljoin(url, download_url)
# Generate filename
if target_filename:
filename = sanitize_filename(target_filename)
else:
# Try to extract filename from URL
filename = download_url.split('/')[-1].split('?')[0]
if not filename or len(filename) < 5:
filename = "uqload_video.mp4"
filename = sanitize_filename(filename)
# Ensure .mp4 extension
if not filename.endswith('.mp4'):
filename += '.mp4'
logger.info(f"Successfully extracted Uqload download link: {filename}")
return download_url, filename
except Exception as e:
logger.error(f"Error extracting Uqload download link: {e}")
raise ValueError(f"Failed to extract download link from Uqload: {str(e)}")
+111
View File
@@ -0,0 +1,111 @@
"""Vidzy video hosting service downloader"""
import logging
from typing import Optional
from .base import BaseVideoPlayer
from bs4 import BeautifulSoup
from app.utils import sanitize_filename
logger = logging.getLogger(__name__)
class VidzyDownloader(BaseVideoPlayer):
"""
Downloader for Vidzy video hosting service.
Vidzy is a video hosting platform used by various anime streaming sites.
"""
def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL"""
return "vidzy" in url.lower()
async def get_download_link(
self,
url: str,
target_filename: Optional[str] = None
) -> tuple[str, str]:
"""
Extract direct download link and filename from Vidzy URL.
Args:
url: The Vidzy video player URL
target_filename: Optional filename override
Returns:
Tuple of (download_url, filename)
"""
try:
logger.info(f"Fetching Vidzy URL: {url}")
# Fetch the page
response = await self.client.get(url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
# Method 1: Look for video source in <video> tag
video_tag = soup.find('video')
if video_tag and video_tag.get('src'):
download_url = video_tag['src']
logger.info(f"Found video source from <video> tag")
else:
# Method 2: Look for source in <source> tag
source_tag = soup.find('source')
if source_tag and source_tag.get('src'):
download_url = source_tag['src']
logger.info(f"Found video source from <source> tag")
else:
# Method 3: Look for video URL in JavaScript
# Vidzy often stores the video URL in a JavaScript variable
scripts = soup.find_all('script')
for script in scripts:
if script.string:
# Look for patterns like 'file:"URL"' or 'file: "URL"'
import re
patterns = [
r'file\s*:\s*["\']([^"\']+\.mp4[^"\']*)["\']',
r'source\s*:\s*["\']([^"\']+\.mp4[^"\']*)["\']',
r'videoUrl\s*:\s*["\']([^"\']+)["\']',
r'"url"\s*:\s*["\']([^"\']+\.mp4[^"\']*)["\']',
]
for pattern in patterns:
match = re.search(pattern, script.string)
if match:
download_url = match.group(1)
logger.info(f"Found video source from JavaScript")
break
if 'download_url' in locals():
break
if 'download_url' not in locals():
raise ValueError("Could not find video URL in page")
# Ensure URL is absolute
if not download_url.startswith('http'):
if download_url.startswith('//'):
download_url = 'https:' + download_url
else:
from urllib.parse import urljoin
download_url = urljoin(url, download_url)
# Generate filename
if target_filename:
filename = sanitize_filename(target_filename)
else:
# Try to extract filename from URL
filename = download_url.split('/')[-1].split('?')[0]
if not filename or len(filename) < 5:
filename = "vidzy_video.mp4"
filename = sanitize_filename(filename)
# Ensure .mp4 extension
if not filename.endswith('.mp4'):
filename += '.mp4'
logger.info(f"Successfully extracted Vidzy download link: {filename}")
return download_url, filename
except Exception as e:
logger.error(f"Error extracting Vidzy download link: {e}")
raise ValueError(f"Failed to extract download link from Vidzy: {str(e)}")
+42 -3
View File
@@ -1,4 +1,4 @@
"""Anime and file hosting providers configuration"""
"""Anime, series and file hosting providers configuration"""
ANIME_PROVIDERS = {
"anime-sama": {
@@ -28,6 +28,23 @@ ANIME_PROVIDERS = {
"url_pattern": "https://vostfree.tv/anime/{slug}",
"icon": "📺",
"color": "#ffd93d"
},
"french-manga": {
"name": "French-Manga",
"domains": ["french-manga.net", "w16.french-manga.net", "w15.french-manga.net", "www.french-manga.net"],
"url_pattern": "https://w16.french-manga.net/{slug}.html",
"icon": "🇫🇷",
"color": "#ff7675"
}
}
SERIES_PROVIDERS = {
"fs7": {
"name": "French Stream",
"domains": ["fs7.lol", "www.fs7.lol", "french-stream.tv", "www.french-stream.tv"],
"url_pattern": "https://fs7.lol/s-tv/{slug}.html",
"icon": "🎬",
"color": "#ff6b9d"
}
}
@@ -79,17 +96,39 @@ FILE_HOSTS = {
"domains": ["lpayer.embed4me.com", "lpayer.com", "lplayer.fr"],
"icon": "▶️",
"color": "#e17055"
},
"vidzy": {
"name": "Vidzy",
"domains": ["vidzy.com", "vidzy.net", "www.vidzy.com"],
"icon": "🎞️",
"color": "#74b9ff"
},
"luluv": {
"name": "LuLuvid",
"domains": ["luluv.com", "luluvid.com", "www.luluv.com", "www.luluvid.com"],
"icon": "🎬",
"color": "#a29bfe"
},
"uqload": {
"name": "Uqload",
"domains": ["uqload.bz", "uqload.com", "www.uqload.bz", "www.uqload.com"],
"icon": "📺",
"color": "#fd79a8"
}
}
def get_all_providers():
"""Get all supported providers (anime + file hosts)"""
return {**ANIME_PROVIDERS, **FILE_HOSTS}
"""Get all supported providers (anime + series + file hosts)"""
return {**ANIME_PROVIDERS, **SERIES_PROVIDERS, **FILE_HOSTS}
def get_anime_providers():
"""Get all anime streaming providers"""
return ANIME_PROVIDERS
def get_series_providers():
"""Get all series streaming providers"""
return SERIES_PROVIDERS
def get_file_hosts():
"""Get all file hosting providers"""
return FILE_HOSTS
+2 -1
View File
@@ -138,9 +138,10 @@ async def root():
@app.get("/api/providers")
async def list_providers():
"""List all supported anime and file hosting providers"""
"""List all supported anime, series and file hosting providers"""
return {
"anime_providers": providers.get_anime_providers(),
"series_providers": providers.get_series_providers(),
"file_hosts": providers.get_file_hosts()
}
+117 -5
View File
@@ -78,6 +78,9 @@
display: inline-flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button svg {
@@ -103,6 +106,9 @@
.btn-small {
padding: 6px 12px;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-pause {
@@ -301,6 +307,8 @@
align-items: center;
margin-bottom: 20px;
margin-top: 40px;
gap: 15px;
flex-wrap: wrap;
}
.section-header h2 {
@@ -309,6 +317,8 @@
background: linear-gradient(45deg, #00d9ff, #00ff88);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
flex: 1;
min-width: 0;
}
.downloads-stats {
@@ -338,6 +348,7 @@
padding: 15px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
overflow-x: auto;
}
.filter-group {
@@ -360,7 +371,8 @@
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 13px;
min-width: 120px;
min-width: 100px;
max-width: 200px;
}
.filter-group select:focus,
@@ -370,7 +382,8 @@
}
.search-group input {
min-width: 200px;
min-width: 150px;
width: 150px;
}
.actions-group {
@@ -641,6 +654,51 @@
.supported-hosts {
gap: 6px;
}
/* Responsive filters */
.downloads-controls {
flex-direction: column;
gap: 10px;
}
.filter-group {
width: 100%;
}
.filter-group select,
.filter-group input {
width: 100%;
min-width: unset;
max-width: unset;
}
.search-group input {
width: 100%;
min-width: unset;
}
.actions-group {
margin-left: 0;
width: 100%;
}
.actions-group button {
width: 100%;
}
/* Responsive horizontal cards */
.anime-card-horizontal {
width: 180px;
max-width: 85vw;
}
.anime-card-horizontal .anime-card-actions {
flex-direction: column;
}
.anime-card-horizontal .anime-card-actions button {
width: 100%;
}
}
@media (max-width: 480px) {
@@ -658,6 +716,38 @@
width: 12px;
height: 12px;
}
/* Even smaller horizontal cards */
.anime-card-horizontal {
width: 160px;
max-width: 80vw;
}
.anime-card-horizontal .anime-card-title {
font-size: 12px;
}
/* Responsive tabs */
.tab {
padding: 8px 12px;
font-size: 12px;
}
/* Section header responsive */
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.section-header h2 {
font-size: 1.4em;
}
/* Search results grid */
.search-results {
grid-template-columns: 1fr;
}
}
/* Recommendations & Releases Cards */
@@ -778,13 +868,15 @@
.anime-card-horizontal {
flex: 0 0 auto;
width: 200px;
width: 220px;
max-width: 90vw;
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s;
cursor: pointer;
overflow: hidden;
}
.recommendation-card {
@@ -821,6 +913,11 @@
color: #fff;
flex: 1;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.anime-card-horizontal .anime-card-rating {
@@ -900,9 +997,12 @@
.anime-card-horizontal .anime-card-actions button {
flex: 1;
padding: 6px 10px;
font-size: 11px;
padding: 6px 8px;
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.anime-card-horizontal .anime-synopsis {
@@ -1252,3 +1352,15 @@
grid-template-columns: 1fr;
}
}
/* Large screens optimization */
@media (min-width: 1400px) {
.container {
max-width: 1200px;
}
.recommendations-carousel,
.releases-carousel {
justify-content: flex-start;
}
}
+66 -2
View File
@@ -37,8 +37,8 @@ async function loadProviders() {
// Update anime tabs
const animeTabsContainer = document.querySelector('.tabs');
const existingTabs = animeTabsContainer.querySelectorAll('.tab[data-tab-type="anime"]');
existingTabs.forEach(tab => tab.remove());
const existingAnimeTabs = animeTabsContainer.querySelectorAll('.tab[data-tab-type="anime"]');
existingAnimeTabs.forEach(tab => tab.remove());
// Add anime provider tabs
Object.entries(data.anime_providers).forEach(([id, provider]) => {
@@ -64,6 +64,33 @@ async function loadProviders() {
}
});
// Add series provider tabs
const existingSeriesTabs = animeTabsContainer.querySelectorAll('.tab[data-tab-type="series"]');
existingSeriesTabs.forEach(tab => tab.remove());
Object.entries(data.series_providers || {}).forEach(([id, provider]) => {
// Check if tab doesn't exist
if (!document.querySelector(`.tab[data-provider="${id}"]`)) {
const button = document.createElement('button');
button.className = 'tab';
button.setAttribute('data-tab-type', 'series');
button.setAttribute('data-provider', id);
button.innerHTML = `${provider.icon} ${provider.name}`;
button.onclick = () => switchTab(`series-${id}`);
animeTabsContainer.appendChild(button);
// Create corresponding tab content
const tabContent = document.createElement('div');
tabContent.id = `tab-series-${id}`;
tabContent.className = 'tab-content';
tabContent.innerHTML = createSeriesTabContent(id, provider);
document.querySelector('.container').insertBefore(
tabContent,
document.getElementById('downloadsList')
);
}
});
// Update supported hosts badges
const hostsContainer = document.querySelector('.supported-hosts');
hostsContainer.innerHTML = '';
@@ -115,6 +142,41 @@ function createAnimeTabContent(providerId, provider) {
`;
}
/**
* Create series provider tab content
*/
function createSeriesTabContent(providerId, provider) {
return `
<div class="url-form">
<div class="anime-input-group">
<input
type="text"
id="${providerId}UrlInput"
placeholder="URL de la série (ex: ${provider.url_pattern})"
>
<button type="button" class="btn-primary" onclick="handleLoadProviderEpisodes('${providerId}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Charger
</button>
</div>
<div id="${providerId}EpisodeSelector" class="anime-select-group" style="display:none;">
<select id="${providerId}EpisodeSelect">
<option value="">Sélectionner un épisode</option>
</select>
<button type="button" class="btn-primary" onclick="handleDownloadProviderEpisode('${providerId}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Télécharger
</button>
</div>
</div>
`;
}
/**
* Handle load provider episodes
*/
@@ -200,6 +262,8 @@ function switchTab(tabName) {
btn.classList.add('active');
} else if (tabType === 'anime' && btn.getAttribute('data-provider') === tabName.replace('anime-', '')) {
btn.classList.add('active');
} else if (tabType === 'series' && btn.getAttribute('data-provider') === tabName.replace('series-', '')) {
btn.classList.add('active');
}
});
+92
View File
@@ -0,0 +1,92 @@
"""Tests for French-Manga integration"""
import pytest
from app.downloaders.video_players import VidzyDownloader, LuLuvidDownloader
from app.downloaders.anime_sites import FrenchMangaDownloader
from app.downloaders import get_downloader
from app.providers import detect_provider_from_url
class TestVidzyDownloader:
"""Tests for Vidzy video player downloader"""
def test_can_handle_vidzy_urls(self):
"""Test that Vidzy downloader correctly identifies Vidzy URLs"""
downloader = VidzyDownloader()
assert downloader.can_handle("https://vidzy.com/v/abc123") == True
assert downloader.can_handle("https://www.vidzy.com/embed/xyz") == True
assert downloader.can_handle("http://vidzy.net/video/test") == True
assert downloader.can_handle("https://doodstream.com/test") == False
assert downloader.can_handle("https://vidmoly.to/test") == False
class TestLuLuvidDownloader:
"""Tests for LuLuvid video player downloader"""
def test_can_handle_luluv_urls(self):
"""Test that LuLuvid downloader correctly identifies LuLuvid URLs"""
downloader = LuLuvidDownloader()
assert downloader.can_handle("https://luluv.com/v/abc123") == True
assert downloader.can_handle("https://www.luluv.com/embed/xyz") == True
assert downloader.can_handle("https://luluvid.com/video/test") == True
assert downloader.can_handle("https://doodstream.com/test") == False
assert downloader.can_handle("https://vidmoly.to/test") == False
class TestFrenchMangaDownloader:
"""Tests for French-Manga anime site downloader"""
def test_can_handle_french_manga_urls(self):
"""Test that French-Manga downloader correctly identifies French-Manga URLs"""
downloader = FrenchMangaDownloader()
assert downloader.can_handle("https://french-manga.net/test.html") == True
assert downloader.can_handle("https://w16.french-manga.net/anime-test") == True
assert downloader.can_handle("https://www.french-manga.net/test") == True
assert downloader.can_handle("https://anime-sama.si/test") == False
assert downloader.can_handle("https://neko-sama.fr/test") == False
def test_initialization(self):
"""Test that FrenchMangaDownloader initializes correctly"""
downloader = FrenchMangaDownloader()
assert downloader.base_url == "https://w16.french-manga.net"
assert downloader.client is not None
class TestProviderDetection:
"""Tests for provider detection from URLs"""
def test_detect_french_manga_provider(self):
"""Test detection of French-Manga provider"""
provider = detect_provider_from_url("https://french-manga.net/anime-test.html")
assert provider == "french-manga"
def test_detect_vidzy_provider(self):
"""Test detection of Vidzy provider"""
provider = detect_provider_from_url("https://vidzy.com/v/test123")
assert provider == "vidzy"
def test_detect_luluv_provider(self):
"""Test detection of LuLuvid provider"""
provider = detect_provider_from_url("https://luluv.com/v/test123")
assert provider == "luluv"
class TestFactoryIntegration:
"""Tests for factory function integration"""
def test_french_manga_factory(self):
"""Test that factory returns FrenchMangaDownloader for French-Manga URLs"""
downloader = get_downloader("https://french-manga.net/test.html")
assert isinstance(downloader, FrenchMangaDownloader)
def test_vidzy_factory(self):
"""Test that factory returns VidzyDownloader for Vidzy URLs"""
downloader = get_downloader("https://vidzy.com/v/test123")
assert isinstance(downloader, VidzyDownloader)
def test_luluv_factory(self):
"""Test that factory returns LuLuvidDownloader for LuLuvid URLs"""
downloader = get_downloader("https://luluv.com/v/test123")
assert isinstance(downloader, LuLuvidDownloader)