feat: Add series TV support with Vidzy HLS downloads and duplicate prevention

Major improvements:
- Series TV support via FS7 provider with dedicated search endpoint
- Vidzy downloader now uses Playwright for JS obfuscation and ffmpeg for HLS streams
- Episode filenames properly named (Series Title - Episode X) instead of master.m3u8.mp4
- Duplicate download prevention: checks existing tasks before creating new ones
- Removed host preference system in favor of intelligent URL-based detection

Technical changes:
- Vidzy: Added Playwright extraction and M3U8→MP4 conversion with ffmpeg
- FS7: Episodes now use pipe format (video_url|series_url|episode_title)
- DownloadManager: Extract target_filename from pipe URL and prevent duplicates
- UI: New Series tab with search, recommendations, and releases sections
- Anime-Sama: Removed hardcoded host preferences, uses site's URL order

Generated with [Claude Code](https://claude.com/claude-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 20:42:29 +00:00
parent 5e50081b58
commit c1c31d7685
17 changed files with 938 additions and 219 deletions
+30 -1
View File
@@ -30,6 +30,25 @@ class DownloadManager:
return list(self.tasks.values()) return list(self.tasks.values())
def create_task(self, request: DownloadRequest) -> DownloadTask: def create_task(self, request: DownloadRequest) -> DownloadTask:
# Check for existing tasks with the same URL
# Extract actual URL from pipe-separated format
url_to_check = request.url.split('|')[0] if '|' in request.url else request.url
# Look for existing non-failed tasks with the same URL
for existing_task in self.tasks.values():
existing_url = existing_task.url.split('|')[0] if '|' in existing_task.url else existing_task.url
# If same URL and task is not failed/cancelled/completed
if existing_url == url_to_check and existing_task.status not in [
DownloadStatus.FAILED,
DownloadStatus.CANCELLED,
DownloadStatus.COMPLETED
]:
logger.info(f"Duplicate download detected: {url_to_check[:80]}...")
logger.info(f"Returning existing task: {existing_task.id}")
return existing_task
# No duplicate found, create new task
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
task = DownloadTask( task = DownloadTask(
id=task_id, id=task_id,
@@ -103,7 +122,17 @@ class DownloadManager:
# Get downloader and extract link # Get downloader and extract link
downloader = get_downloader(task.url) downloader = get_downloader(task.url)
download_url, filename = await downloader.get_download_link(task.url)
# Extract episode title from pipe-separated URL if present
# Format: video_url|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}")
download_url, filename = await downloader.get_download_link(task.url, target_filename)
logger.info(f"Download URL: {download_url[:100] if len(download_url) > 100 else download_url}") logger.info(f"Download URL: {download_url[:100] if len(download_url) > 100 else download_url}")
logger.debug(f"Downloader filename: {filename}") logger.debug(f"Downloader filename: {filename}")
+18 -28
View File
@@ -424,7 +424,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
filename = target_filename if target_filename else temp_filename filename = target_filename if target_filename else temp_filename
print(f"[ANIME-SAMA] Got video: {filename}") print(f"[ANIME-SAMA] Got video: {filename}")
print(f"[ANIME-SAMA] Video URL: {video_url[:100]}...") print(f"[ANIME-SAMA] Video URL: {video_url[:100] if video_url else 'None'}...")
# Return the direct video URL # Return the direct video URL
# The download_manager will handle the actual download # The download_manager will handle the actual download
@@ -432,7 +432,8 @@ class AnimeSamaDownloader(BaseAnimeSite):
except Exception as e: except Exception as e:
print(f"[ANIME-SAMA] Lpayer extraction error: {e}") print(f"[ANIME-SAMA] Lpayer extraction error: {e}")
raise Exception(f"Error extracting from lpayer: {str(e)}") # 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_player(self, player_url: str) -> str | None: async def _extract_from_player(self, player_url: str) -> str | None:
"""Try to extract direct video URL from player iframe""" """Try to extract direct video URL from player iframe"""
@@ -783,7 +784,8 @@ class AnimeSamaDownloader(BaseAnimeSite):
print(f"[ANIME-SAMA] Detected format {'A (source-based)' if is_format_a else 'B (episode-based)'} - eps1 has {len(eps1_urls)} URLs") print(f"[ANIME-SAMA] Detected format {'A (source-based)' if is_format_a else 'B (episode-based)'} - eps1 has {len(eps1_urls)} URLs")
host_preference = ['sibnet.ru', 'vidmoly', 'sendvid', 'lpayer'] # No more host preference! Just collect all available URLs for each episode
# The download system will automatically detect and use the appropriate downloader
all_episodes_by_number = {} all_episodes_by_number = {}
if is_format_a: if is_format_a:
@@ -797,48 +799,36 @@ class AnimeSamaDownloader(BaseAnimeSite):
if episode_num not in all_episodes_by_number: if episode_num not in all_episodes_by_number:
all_episodes_by_number[episode_num] = [] all_episodes_by_number[episode_num] = []
# Determine host preference score (lower = better) all_episodes_by_number[episode_num].append(url)
host_score = len(host_preference)
for i, host in enumerate(host_preference):
if host in url.lower():
host_score = i
break
all_episodes_by_number[episode_num].append((host_score, url))
else: else:
# Format B: Each epsX is an episode, containing multiple sources # Format B: Each epsX is an episode, containing multiple sources
for eps_num, urls_text in eps_matches: for eps_num, urls_text in eps_matches:
episode_num = str(eps_num).zfill(2) episode_num = str(eps_num).zfill(2)
episode_urls = re.findall(r"'(https?://[^']+)'", urls_text) episode_urls = re.findall(r"'(https?://[^']+)'", urls_text)
for url in episode_urls: if episode_num not in all_episodes_by_number:
if episode_num not in all_episodes_by_number: all_episodes_by_number[episode_num] = []
all_episodes_by_number[episode_num] = []
# Determine host preference score (lower = better) all_episodes_by_number[episode_num].extend(episode_urls)
host_score = len(host_preference)
for i, host in enumerate(host_preference):
if host in url.lower():
host_score = i
break
all_episodes_by_number[episode_num].append((host_score, url)) # For each episode, use the first available URL
# (they are usually already in order of preference on the site)
# For each episode, use the best available URL (lowest score = best host)
for episode_num in sorted(all_episodes_by_number.keys()): for episode_num in sorted(all_episodes_by_number.keys()):
sorted_urls = sorted(all_episodes_by_number[episode_num], key=lambda x: x[0]) available_urls = all_episodes_by_number[episode_num]
best_url = sorted_urls[0][1] # Get the URL with lowest score (best host)
# Use the first available URL (the site usually lists them in preference order)
episode_url = available_urls[0]
episode_title = f'Episode {episode_num}' episode_title = f'Episode {episode_num}'
combined_url = f"{best_url}|{anime_url}|{episode_title}" combined_url = f"{episode_url}|{anime_url}|{episode_title}"
episodes.append({ episodes.append({
'episode': episode_num, 'episode': episode_num,
'url': combined_url, 'url': combined_url,
'title': episode_title 'title': episode_title,
'available_hosts': len(available_urls) # Store count of available hosts
}) })
print(f"[ANIME-SAMA] Found {len(episodes)} episodes (prioritizing {host_preference})") print(f"[ANIME-SAMA] Found {len(episodes)} episodes")
return episodes return episodes
except Exception as e: except Exception as e:
+26 -2
View File
@@ -89,6 +89,10 @@ class FS7Downloader(BaseSeriesSite):
continue continue
title = text 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 # Extract cover image
img = item.find('img') img = item.find('img')
cover_image = img.get('src', '') if img else '' cover_image = img.get('src', '') if img else ''
@@ -135,6 +139,12 @@ class FS7Downloader(BaseSeriesSite):
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, 'lxml')
episodes = [] 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 # FS7 stores episode data in JavaScript div elements
# Format: <div data-ep="1" data-vidzy="..." data-uqload="..." data-netu="..." data-voe="..."></div> # Format: <div data-ep="1" data-vidzy="..." data-uqload="..." data-netu="..." data-voe="..."></div>
episode_divs = soup.find_all('div', attrs={'data-ep': True}) episode_divs = soup.find_all('div', attrs={'data-ep': True})
@@ -144,17 +154,28 @@ class FS7Downloader(BaseSeriesSite):
# Try different video players in order of preference # Try different video players in order of preference
video_url = None video_url = None
host_name = None
for player in ['data-vidzy', 'data-uqload', 'data-voe', 'data-netu']: for player in ['data-vidzy', 'data-uqload', 'data-voe', 'data-netu']:
player_url = div.get(player, '').strip() player_url = div.get(player, '').strip()
if player_url: if player_url:
video_url = player_url video_url = player_url
logger.debug(f"Found episode {ep_num} on {player}") # Extract host name from attribute name
host_name = player.replace('data-', '').title()
logger.debug(f"Found episode {ep_num} on {host_name}")
break break
if video_url and ep_num: 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({ episodes.append({
'episode': ep_num, 'episode': ep_num,
'url': video_url 'url': combined_url,
'title': episode_title,
'host': host_name or 'Unknown'
}) })
# Sort by episode number # Sort by episode number
@@ -193,6 +214,9 @@ class FS7Downloader(BaseSeriesSite):
title = soup.find('h1') title = soup.find('h1')
title = title.get_text(strip=True) if title else "Unknown" 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 # Extract description/synopsis
description_elem = soup.find('div', class_='full-text') description_elem = soup.find('div', class_='full-text')
description = description_elem.get_text(strip=True) if description_elem else "" description = description_elem.get_text(strip=True) if description_elem else ""
+289 -56
View File
@@ -1,5 +1,9 @@
"""Vidzy video hosting service downloader""" """Vidzy video hosting service downloader"""
import logging import logging
import asyncio
import re
import subprocess
import os
from typing import Optional from typing import Optional
from .base import BaseVideoPlayer from .base import BaseVideoPlayer
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -13,6 +17,7 @@ class VidzyDownloader(BaseVideoPlayer):
Downloader for Vidzy video hosting service. Downloader for Vidzy video hosting service.
Vidzy is a video hosting platform used by various anime streaming sites. Vidzy is a video hosting platform used by various anime streaming sites.
Uses heavy JavaScript obfuscation, so Playwright is required.
""" """
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
@@ -35,9 +40,206 @@ class VidzyDownloader(BaseVideoPlayer):
Tuple of (download_url, filename) Tuple of (download_url, filename)
""" """
try: try:
# Extract actual Vidzy URL from pipe-separated format if present
# Format: video_url|anime_url|episode_title
if '|' in url:
url = url.split('|')[0].strip()
logger.debug(f"Extracted Vidzy URL from pipe format: {url}")
logger.info(f"Fetching Vidzy URL: {url}") logger.info(f"Fetching Vidzy URL: {url}")
# Fetch the page # Try using Playwright first (Vidzy uses heavy JS obfuscation)
video_url = await self._extract_with_playwright(url)
if not video_url:
# Fallback to static HTML parsing
logger.warning("Playwright extraction failed, trying static parsing...")
video_url = await self._extract_static(url)
if not video_url:
raise ValueError(f"Could not extract video URL from Vidzy")
logger.info(f"Successfully extracted Vidzy URL: {video_url[:100]}...")
# Generate filename
if target_filename:
filename = sanitize_filename(target_filename)
else:
# Try to extract filename from URL
filename = video_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'
# Check if it's an M3U8 playlist (HLS stream)
if '.m3u8' in video_url:
logger.info(f"Detected M3U8 stream, will download with ffmpeg")
# Download and convert M3U8 to MP4 directly
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://vidzy.org/',
}
mp4_path = await self._download_m3u8_as_mp4(video_url, filename, headers)
logger.info(f"Successfully extracted Vidzy download link: {filename}")
return mp4_path, filename
# It's a direct MP4 link
logger.info(f"Successfully extracted Vidzy download link: {filename}")
return video_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)}")
async def _extract_with_playwright(self, url: str) -> Optional[str]:
"""Extract video URL using Playwright with network interception"""
try:
from playwright.async_api import async_playwright
logger.info("Launching Playwright for Vidzy...")
video_urls = []
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
)
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
)
page = await context.new_page()
# Set up request interception
async def handle_request(route):
req_url = route.request.url
# Look for video files (HLS streams and MP4s)
if any(ext in req_url.lower() for ext in ['.m3u8', '.mp4', 'master']):
if 'vidzy' not in req_url.lower() or 'master' in req_url.lower():
logger.info(f"🎥 Captured video URL: {req_url[:100]}...")
video_urls.append(req_url)
await route.continue_()
await page.route('**', handle_request)
logger.info("Navigating to Vidzy page...")
try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
except Exception as e:
logger.warning(f"Navigation warning: {e}")
# Wait for page to load and initialize player
logger.info("Waiting for video player to load...")
await asyncio.sleep(5)
# Try JavaScript extraction from VideoJS player
try:
js_result = await page.evaluate("""
() => {
// Check if videojs is available
if (typeof videojs !== 'undefined' && videojs.players) {
// Get all players
const players = Object.values(videojs.players);
if (players.length > 0) {
const player = players[0];
// Try to get source from player
if (player.currentSrc()) {
return player.currentSrc();
}
// Try to get sources array
if (player.currentSources() && player.currentSources().length > 0) {
return player.currentSources()[0].src;
}
}
}
// Check all video elements
const videos = document.querySelectorAll('video');
for (let v of videos) {
if (v.src) {
return v.src;
}
const sources = v.querySelectorAll('source');
for (let s of sources) {
if (s.src) {
return s.src;
}
}
}
// Look for sources in scripts (VideoJS config)
const scripts = document.querySelectorAll('script');
for (let script of scripts) {
const text = script.textContent;
// Look for sources array with .m3u8 URLs
const sourcesMatch = text.match(/sources\s*:\s*\[\s*\{\s*src\s*:\s*['"](https?:\/\/[^'"]+\.m3u8[^'"]*)['"]/i);
if (sourcesMatch) {
return sourcesMatch[1];
}
}
return null;
}
""")
if js_result and ('.m3u8' in js_result or '.mp4' in js_result):
logger.info(f"Found video URL via JavaScript evaluation")
video_urls.append(js_result)
except Exception as e:
logger.warning(f"JS extraction error: {e}")
# Wait more for network requests
await asyncio.sleep(3)
await browser.close()
# Return best video URL (prefer master.m3u8 for HLS)
if video_urls:
seen = set()
unique_urls = []
for url in video_urls:
if url not in seen:
seen.add(url)
unique_urls.append(url)
if unique_urls:
logger.info(f"✅ Found {len(unique_urls)} video URL(s)")
# Prefer master.m3u8 (HLS playlist)
for url in unique_urls:
if 'master.m3u8' in url or '.m3u8' in url:
logger.info(f"Using HLS playlist: {url[:100]}...")
return url
# Fall back to first URL
return unique_urls[0]
logger.warning("❌ No video URLs found via Playwright")
return None
except ImportError:
logger.warning("Playwright not installed, falling back to static parsing")
return None
except Exception as e:
logger.warning(f"Playwright error: {e}")
return None
async def _extract_static(self, url: str) -> Optional[str]:
"""Static HTML parsing fallback"""
try:
response = await self.client.get(url) response = await self.client.get(url)
response.raise_for_status() response.raise_for_status()
html = response.text html = response.text
@@ -47,65 +249,96 @@ class VidzyDownloader(BaseVideoPlayer):
# Method 1: Look for video source in <video> tag # Method 1: Look for video source in <video> tag
video_tag = soup.find('video') video_tag = soup.find('video')
if video_tag and video_tag.get('src'): if video_tag and video_tag.get('src'):
download_url = video_tag['src']
logger.info(f"Found video source from <video> tag") logger.info(f"Found video source from <video> tag")
else: return video_tag['src']
# 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(): # Method 2: Look for source in <source> tag
raise ValueError("Could not find video URL in page") source_tag = soup.find('source')
if source_tag and source_tag.get('src'):
logger.info(f"Found video source from <source> tag")
return source_tag['src']
# Ensure URL is absolute # Method 3: Search entire HTML for .m3u8 URLs (Vidzy uses HLS)
if not download_url.startswith('http'): html_patterns = [
if download_url.startswith('//'): r'(https?://[^\s<>"\'`]+\.m3u8[^\s<>"\'`]*)',
download_url = 'https:' + download_url r'(https?://[^\s<>"\'`]+/master[^\s<>"\'`]*)',
else: ]
from urllib.parse import urljoin
download_url = urljoin(url, download_url)
# Generate filename for pattern in html_patterns:
if target_filename: matches = re.findall(pattern, html)
filename = sanitize_filename(target_filename) if matches:
else: # Filter out obvious false positives
# Try to extract filename from URL for match in matches:
filename = download_url.split('/')[-1].split('?')[0] # Accept URLs with 'master' or from video hosts
if not filename or len(filename) < 5: if 'master' in match.lower() or any(host in match for host in ['hls', 'video', 'stream']):
filename = "vidzy_video.mp4" logger.info(f"Found video URL in HTML: {match[:100]}...")
filename = sanitize_filename(filename) return match
# Ensure .mp4 extension logger.warning("Static parsing failed to find video URL")
if not filename.endswith('.mp4'): return None
filename += '.mp4'
logger.info(f"Successfully extracted Vidzy download link: {filename}")
return download_url, filename
except Exception as e: except Exception as e:
logger.error(f"Error extracting Vidzy download link: {e}") logger.warning(f"Static parsing error: {e}")
raise ValueError(f"Failed to extract download link from Vidzy: {str(e)}") return None
async def _download_m3u8_as_mp4(self, m3u8_url: str, filename: str, headers: dict, download_dir: str = "downloads") -> str:
"""Download M3U8 stream and convert to MP4 using ffmpeg"""
# Create downloads directory if it doesn't exist
os.makedirs(download_dir, exist_ok=True)
output_path = os.path.join(download_dir, filename)
# Build headers for ffmpeg - using multiple -headers options
header_args = []
for key, value in headers.items():
header_args.extend(['-headers', f'{key}: {value}'])
cmd = [
'ffmpeg',
*header_args,
'-i', m3u8_url,
'-c', 'copy',
'-bsf:a', 'aac_adtstoasc',
'-y',
output_path
]
try:
logger.info(f"Downloading M3U8 with ffmpeg...")
logger.info(f"URL: {m3u8_url[:80]}...")
logger.info(f"Output: {output_path}")
# Run ffmpeg without capturing output to avoid buffering issues
# Use a log file instead
log_path = output_path + '.log'
with open(log_path, 'w') as log_file:
result = subprocess.run(
cmd,
stdout=log_file,
stderr=log_file,
timeout=600 # 10 minutes for very long videos
)
# Check if file was created even if ffmpeg had issues
if os.path.exists(output_path):
file_size = os.path.getsize(output_path)
if file_size > 1000: # At least 1KB
logger.info(f"✅ Download complete: {file_size / (1024*1024):.2f} MB")
return output_path
# If we get here, something went wrong
raise Exception(f"FFmpeg failed - no output file created")
except subprocess.TimeoutExpired:
# Check if file was created despite timeout
if os.path.exists(output_path):
file_size = os.path.getsize(output_path)
if file_size > 1000: # At least 1KB
logger.warning(f"⚠️ Timeout but file created: {file_size / (1024*1024):.2f} MB")
return output_path
raise Exception("FFmpeg timeout (10 minutes) - video too large")
except FileNotFoundError:
raise Exception("ffmpeg not found - please install ffmpeg: apt install ffmpeg")
except Exception as e:
raise Exception(f"Error downloading M3U8: {str(e)}")
+75 -2
View File
@@ -259,8 +259,9 @@ async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: b
""" """
import time import time
import asyncio import asyncio
from app.providers import get_anime_providers from app.providers import get_anime_providers, get_series_providers
from app.downloaders import AnimeSamaDownloader, AnimeUltimeDownloader, NekoSamaDownloader, VostfreeDownloader from app.downloaders import AnimeSamaDownloader, AnimeUltimeDownloader, NekoSamaDownloader, VostfreeDownloader
from app.downloaders.series_sites import FS7Downloader
print(f"\n[SEARCH] Starting search for '{q}' in {lang} (metadata={include_metadata})") print(f"\n[SEARCH] Starting search for '{q}' in {lang} (metadata={include_metadata})")
start_time = time.time() start_time = time.time()
@@ -275,7 +276,12 @@ async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: b
"vostfree": VostfreeDownloader() "vostfree": VostfreeDownloader()
} }
# Search across all providers in parallel with timeout # Create series downloader instances
series_downloaders = {
"fs7": FS7Downloader()
}
# Search across all anime providers in parallel with timeout
search_tasks = [] search_tasks = []
provider_ids = [] provider_ids = []
@@ -286,6 +292,14 @@ async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: b
search_tasks.append(downloader.search_anime(q, lang, include_metadata=include_metadata)) search_tasks.append(downloader.search_anime(q, lang, include_metadata=include_metadata))
provider_ids.append(provider_id) provider_ids.append(provider_id)
# Search across all series providers in parallel with timeout
for provider_id, provider in get_series_providers().items():
if provider_id in series_downloaders:
downloader = series_downloaders[provider_id]
print(f"[SEARCH] Queueing search on {provider_id} (series)...")
search_tasks.append(downloader.search_anime(q, lang))
provider_ids.append(provider_id)
# Wait for all searches to complete with a timeout per provider # Wait for all searches to complete with a timeout per provider
print(f"[SEARCH] Waiting for {len(search_tasks)} searches...") print(f"[SEARCH] Waiting for {len(search_tasks)} searches...")
search_results = await asyncio.gather(*search_tasks, return_exceptions=True) search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
@@ -311,6 +325,65 @@ async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: b
} }
@app.get("/api/series/search")
async def search_series_unified(q: str, lang: str = "vf"):
"""
Search across all TV series providers (FS7, etc.)
Args:
q: Search query
lang: Language preference (vf, vostfr)
"""
import time
import asyncio
from app.providers import get_series_providers
from app.downloaders.series_sites import FS7Downloader
print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}")
start_time = time.time()
results = {}
# Create series downloader instances
series_downloaders = {
"fs7": FS7Downloader()
}
# Search across all series providers in parallel
search_tasks = []
provider_ids = []
for provider_id, provider in get_series_providers().items():
if provider_id in series_downloaders:
downloader = series_downloaders[provider_id]
print(f"[SERIES SEARCH] Queueing search on {provider_id}...")
search_tasks.append(downloader.search_anime(q, lang))
provider_ids.append(provider_id)
# Wait for all searches to complete with a timeout per provider
print(f"[SERIES SEARCH] Waiting for {len(search_tasks)} searches...")
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
# Combine results
for provider_id, result in zip(provider_ids, search_results):
if isinstance(result, Exception):
print(f"[SERIES SEARCH] {provider_id} error: {str(result)}")
elif result:
print(f"[SERIES SEARCH] {provider_id} found {len(result)} results")
results[provider_id] = result
else:
print(f"[SERIES SEARCH] {provider_id} no results")
elapsed = time.time() - start_time
print(f"[SERIES SEARCH] Completed in {elapsed:.2f}s - Total results: {sum(len(r) for r in results.values())}\n")
return {
"query": q,
"lang": lang,
"results": results
}
@app.get("/api/anime/metadata") @app.get("/api/anime/metadata")
async def get_anime_metadata(url: str): async def get_anime_metadata(url: str):
""" """
+13
View File
@@ -338,8 +338,21 @@ async function handleSearch() {
await searchAnimeDetails(query); await searchAnimeDetails(query);
} }
// Handle anime search (new dedicated function)
async function handleAnimeSearch() {
const searchInput = document.getElementById('animeSearchInput') || document.getElementById('searchInput');
if (!searchInput) return;
const query = searchInput.value.trim();
if (!query) return;
// Use the new anime details search
await searchAnimeDetails(query);
}
// Ensure global scope // Ensure global scope
window.handleSearch = handleSearch; window.handleSearch = handleSearch;
window.handleAnimeSearch = handleAnimeSearch;
/** /**
* Handle direct download form submission * Handle direct download form submission
+12
View File
@@ -149,3 +149,15 @@ async function cancelDownload(id) {
return await response.json(); return await response.json();
} }
// Make functions available globally
window.getProvidersInfo = getProvidersInfo;
window.searchAnime = searchAnime;
window.loadEpisodes = loadEpisodes;
window.downloadEpisode = downloadEpisode;
window.downloadSeason = downloadSeason;
window.startDownload = startDownload;
window.getDownloads = getDownloads;
window.pauseDownload = pauseDownload;
window.resumeDownload = resumeDownload;
window.cancelDownload = cancelDownload;
+15 -9
View File
@@ -17,12 +17,22 @@ document.addEventListener('DOMContentLoaded', () => {
* Initialize form event listeners * Initialize form event listeners
*/ */
function initializeForms() { function initializeForms() {
// Search form // Anime search form
const searchInput = document.getElementById('searchInput'); const animeSearchInput = document.getElementById('animeSearchInput');
if (searchInput) { if (animeSearchInput) {
searchInput.addEventListener('keypress', (e) => { animeSearchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSearch(); handleAnimeSearch();
}
});
}
// Series search form
const seriesSearchInput = document.getElementById('seriesSearchInput');
if (seriesSearchInput) {
seriesSearchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleSeriesSearch();
} }
}); });
} }
@@ -209,10 +219,6 @@ function switchTab(tabName) {
if (tabType === 'home' && tabName === 'home') { if (tabType === 'home' && tabName === 'home') {
btn.classList.add('active'); btn.classList.add('active');
} else if (tabType === 'search' && tabName === 'search') {
btn.classList.add('active');
} else if (tabType === 'direct' && tabName === 'direct') {
btn.classList.add('active');
} else if (tabType === 'anime' && tabName === 'anime') { } else if (tabType === 'anime' && tabName === 'anime') {
// Static anime tab // Static anime tab
btn.classList.add('active'); btn.classList.add('active');
+6 -6
View File
@@ -253,20 +253,20 @@ function getRatingColor(score) {
return 'linear-gradient(45deg, #666, #888)'; return 'linear-gradient(45deg, #666, #888)';
} }
// Search anime on providers (redirects to search tab) // Search anime on providers (redirects to anime tab)
function searchAnimeOnProviders(title) { function searchAnimeOnProviders(title) {
// Switch to search tab // Switch to anime tab
switchTab('search'); switchTab('anime');
// Fill search input // Fill search input
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('animeSearchInput');
if (searchInput) { if (searchInput) {
searchInput.value = title; searchInput.value = title;
// Trigger search // Trigger search
setTimeout(() => { setTimeout(() => {
if (typeof searchAnime === 'function') { if (typeof handleAnimeSearch === 'function') {
searchAnime(); handleAnimeSearch();
} }
}, 300); }, 300);
} }
+169
View File
@@ -0,0 +1,169 @@
/**
* Series search functionality for FS7
*/
// Handle series search
async function handleSeriesSearch() {
const searchInput = document.getElementById('seriesSearchInput');
const resultsContainer = document.getElementById('seriesSearchResults');
if (!searchInput || !resultsContainer) return;
const query = searchInput.value.trim();
if (!query) {
alert('Veuillez entrer un nom de série');
return;
}
try {
resultsContainer.innerHTML = '<div class="loading-spinner">Recherche de séries TV en cours...</div>';
// Search on series providers using the dedicated endpoint
const response = await fetch(`${API_BASE}/series/search?q=${encodeURIComponent(query)}&lang=vf`);
const data = await response.json();
if (data.results && data.results['fs7'] && data.results['fs7'].length > 0) {
const series = data.results['fs7'];
let html = `
<div class="streaming-results-header">
<h3>📺 Résultats pour "${escapeHtml(query)}"</h3>
</div>
<div class="search-results" style="margin-top: 20px;">
`;
series.forEach(s => {
let coverImage = s.cover_image || '';
// Convert relative poster.php URLs to absolute URLs
if (coverImage.startsWith('/poster.php?url=')) {
const actualUrl = coverImage.replace('/poster.php?url=', '');
coverImage = actualUrl;
} else if (coverImage.startsWith('/')) {
coverImage = 'https://fs7.lol' + coverImage;
}
html += `
<div class="anime-card" id="series-fs7-${encodeURIComponent(s.url)}">
<div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(s.title)}</div>
<div class="anime-card-provider">📺 French Stream</div>
</div>
${coverImage ? `
<div style="text-align: center; margin: 10px 0;">
<img src="${escapeHtml(coverImage)}" alt="" style="max-width: 200px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.3);" onerror="this.style.display='none'">
</div>
` : ''}
<div class="anime-card-actions">
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(s.url)}', '_blank')">
🔗 Voir sur FS7
</button>
<button class="btn-primary btn-small" onclick="loadSeriesEpisodesDirect('${escapeHtml(s.url)}', '${escapeHtml(s.title)}')">
📥 Voir les épisodes
</button>
</div>
<div id="episodes-fs7-${encodeURIComponent(s.url)}" style="margin-top: 10px;"></div>
</div>
`;
});
html += '</div>';
resultsContainer.innerHTML = html;
} else {
resultsContainer.innerHTML = `
<div class="no-results">
<p>❌ Aucune série trouvée pour "${escapeHtml(query)}"</p>
<p style="font-size: 12px; margin-top: 10px; opacity: 0.7;">
Essayez avec un autre titre ou vérifiez l'orthographe
</p>
</div>`;
}
} catch (error) {
console.error('Error searching series:', error);
resultsContainer.innerHTML = `
<div class="no-results">
<p>❌ Erreur lors de la recherche</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
</div>`;
}
}
// Load series episodes directly without redirecting to search
async function loadSeriesEpisodesDirect(url, title) {
const episodesContainer = document.getElementById(`episodes-fs7-${encodeURIComponent(url)}`);
if (!episodesContainer) return;
try {
episodesContainer.innerHTML = '<div class="loading-spinner">Chargement des épisodes...</div>';
const response = await fetch(`${API_BASE}/anime/episodes?url=${encodeURIComponent(url)}&lang=vf`);
const data = await response.json();
if (data.episodes && data.episodes.length > 0) {
let html = `
<div style="margin-top: 15px;">
<label style="font-size: 12px; color: #00d9ff; margin-bottom: 5px; display: block;">
📺 Sélectionner un épisode:
</label>
<select id="select-episodes-${encodeURIComponent(url)}" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid rgba(0, 217, 255, 0.3); background: rgba(0, 0, 0, 0.3); color: white;">
<option value="">Sélectionner un épisode</option>
${data.episodes.map(ep => `
<option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option>
`).join('')}
</select>
<button class="btn-primary" style="margin-top: 10px; width: 100%;" onclick="downloadSeriesEpisode('${escapeHtml(url)}', '${escapeHtml(title)}')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;">
<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 l'épisode
</button>
</div>
`;
episodesContainer.innerHTML = html;
} else {
episodesContainer.innerHTML = '<div class="no-results" style="margin-top: 10px;">Aucun épisode disponible</div>';
}
} catch (error) {
console.error('Error loading episodes:', error);
episodesContainer.innerHTML = `<div class="no-results" style="margin-top: 10px; color: #ff6b6b;">Erreur: ${error.message}</div>`;
}
}
// Download series episode
async function downloadSeriesEpisode(url, title) {
const select = document.getElementById(`select-episodes-${encodeURIComponent(url)}`);
if (!select || !select.value) {
alert('Veuillez sélectionner un épisode');
return;
}
const episodeUrl = select.value;
try {
const response = await fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(episodeUrl)}`, {
method: 'POST'
});
if (response.ok) {
alert(`✅ Téléchargement démarré pour "${title}"`);
// Refresh downloads
if (typeof loadDownloads === 'function') {
loadDownloads();
}
} else {
const error = await response.json();
const errorMessage = error.detail
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
: 'Impossible de démarrer le téléchargement';
alert(`❌ Erreur: ${errorMessage}`);
}
} catch (error) {
console.error('Download error:', error);
alert(`❌ Erreur lors du téléchargement: ${error.message}`);
}
}
// Make functions available globally
window.handleSeriesSearch = handleSeriesSearch;
window.loadSeriesEpisodesDirect = loadSeriesEpisodesDirect;
window.downloadSeriesEpisode = downloadSeriesEpisode;
+204 -9
View File
@@ -2,6 +2,158 @@
* New tabs functionality * New tabs functionality
*/ */
// Render series recommendation card (same design as anime recommendations)
function renderSeriesRecommendationCard(series) {
let coverImage = series.cover_image || '';
// Convert relative poster.php URLs to absolute URLs
if (coverImage.startsWith('/poster.php?url=')) {
// Extract the actual image URL from the poster.php URL
const actualUrl = coverImage.replace('/poster.php?url=', '');
coverImage = actualUrl;
}
// If it's a relative path, make it absolute using FS7 base URL
else if (coverImage.startsWith('/')) {
coverImage = 'https://fs7.lol' + coverImage;
}
return `
<div class="anime-card-horizontal recommendation-card">
<div class="recommendation-badge">🎺 Série TV populaire</div>
<div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(series.title)}</div>
</div>
<div class="anime-card-content">
${coverImage ? `<img src="${escapeHtml(coverImage)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
<div class="anime-card-info">
<div class="anime-card-meta">
📺 Série TV
</div>
</div>
</div>
<div class="anime-card-actions">
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
🔗 Voir sur FS7
</button>
<button class="btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
📥 Voir les épisodes
</button>
</div>
</div>
`;
}
// Load series episodes (redirects to series tab with search)
async function loadSeriesEpisodes(url, title) {
// Switch to series tab
switchTab('series');
// Fill search input with the series title
const searchInput = document.getElementById('seriesSearchInput');
if (searchInput) {
searchInput.value = title;
// Trigger search
setTimeout(() => {
if (typeof handleSeriesSearch === 'function') {
handleSeriesSearch();
}
}, 300);
}
}
// Render series release card (same design as anime releases)
function renderSeriesReleaseCard(series) {
let coverImage = series.cover_image || '';
// Convert relative poster.php URLs to absolute URLs
if (coverImage.startsWith('/poster.php?url=')) {
// Extract the actual image URL from the poster.php URL
const actualUrl = coverImage.replace('/poster.php?url=', '');
coverImage = actualUrl;
}
// If it's a relative path, make it absolute using FS7 base URL
else if (coverImage.startsWith('/')) {
coverImage = 'https://fs7.lol' + coverImage;
}
return `
<div class="anime-card-horizontal release-card">
<div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(series.title)}</div>
</div>
<div class="anime-card-content">
${coverImage ? `<img src="${escapeHtml(coverImage)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
<div class="anime-card-info">
<div class="anime-card-meta">
📺 Série TV • Nouveau
</div>
</div>
</div>
<div class="anime-card-actions">
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
🔗 Voir sur FS7
</button>
<button class="btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
📥 Voir les épisodes
</button>
</div>
</div>
`;
}
// Load series recommendations for the Series tab
async function loadSeriesRecommendations() {
try {
const container = document.getElementById('seriesRecommendationsList');
if (!container) return;
container.innerHTML = '<div class="loading-spinner">Chargement des recommandations séries...</div>';
// Search for popular series from all providers (including FS7)
const searchTerms = ['Breaking Bad', 'Game of Thrones', 'Stranger Things', 'The Walking Dead', 'The Last of Us', 'Wednesday', 'Squid Game', 'Peaky Blinders', 'House of the Dragon', 'The Witcher'];
const allSeries = [];
for (const term of searchTerms) {
try {
const response = await fetch(`${API_BASE}/series/search?q=${encodeURIComponent(term)}&lang=vf`);
const data = await response.json();
// Collect results from all providers, especially fs7
if (data.results) {
// Prioritize fs7 results
if (data.results['fs7'] && data.results['fs7'].length > 0) {
allSeries.push(...data.results['fs7'].slice(0, 2));
}
}
} catch (error) {
console.warn(`Error searching for ${term}:`, error);
}
if (allSeries.length >= 12) break; // Limit to 12 series total
}
if (allSeries.length > 0) {
container.innerHTML = `<div class="recommendations-carousel">${allSeries.map(series =>
renderSeriesRecommendationCard(series)
).join('')}</div>`;
} else {
container.innerHTML = '<div class="no-results">Aucune recommandation trouvée</div>';
}
} catch (error) {
console.error('Error loading series recommendations:', error);
const container = document.getElementById('seriesRecommendationsList');
if (container) container.innerHTML = '<div class="no-results">Erreur lors du chargement</div>';
}
}
// Load anime releases for the Anime tab // Load anime releases for the Anime tab
async function loadAnimeReleases() { async function loadAnimeReleases() {
try { try {
@@ -34,23 +186,63 @@ async function loadSeriesReleases() {
const container = document.getElementById('seriesReleasesList'); const container = document.getElementById('seriesReleasesList');
if (!container) return; if (!container) return;
container.innerHTML = '<div class="loading-spinner">Chargement des dernières sorties séries...</div>'; container.innerHTML = '<div class="loading-spinner">Chargement des dernières séries TV...</div>';
// For series, we'll show the same releases but could filter later // Search for popular series from all providers (including FS7)
const response = await fetch(`${API_BASE}/releases/latest?limit=12`); const searchTerms = ['Breaking Bad', 'Game of Thrones', 'Stranger Things', 'The Walking Dead', 'The Last of Us', 'Wednesday', 'Squid Game', 'Peaky Blinders'];
const data = await response.json(); const allSeries = [];
if (data.releases && data.releases.length > 0) { for (const term of searchTerms) {
container.innerHTML = `<div class="releases-carousel">${data.releases.map(anime => try {
renderReleaseCard({...anime, title: anime.title + ' [Série]'}) const response = await fetch(`${API_BASE}/series/search?q=${encodeURIComponent(term)}&lang=vf`);
const data = await response.json();
// Collect results from all providers, especially fs7
if (data.results) {
// Prioritize fs7 results
if (data.results['fs7'] && data.results['fs7'].length > 0) {
allSeries.push(...data.results['fs7'].slice(0, 2));
}
// Add results from other providers if needed
for (const [provider, results] of Object.entries(data.results)) {
if (provider !== 'fs7' && results.length > 0 && allSeries.length < 12) {
allSeries.push(...results.slice(0, 1));
}
}
}
} catch (error) {
console.warn(`Error searching for ${term}:`, error);
}
if (allSeries.length >= 12) break; // Limit to 12 series total
}
if (allSeries.length > 0) {
container.innerHTML = `<div class="releases-carousel">${allSeries.map(series =>
renderSeriesReleaseCard(series)
).join('')}</div>`; ).join('')}</div>`;
} else { } else {
container.innerHTML = '<div class="no-results">Aucune sortie trouvée</div>'; container.innerHTML = `
<div class="no-results">
<p>Aucune série trouvée</p>
<p style="font-size: 12px; margin-top: 10px; opacity: 0.7;">
Utilisez l'onglet "Recherche" pour trouver des séries spécifiques
</p>
</div>`;
} }
} catch (error) { } catch (error) {
console.error('Error loading series releases:', error); console.error('Error loading series releases:', error);
const container = document.getElementById('seriesReleasesList'); const container = document.getElementById('seriesReleasesList');
if (container) container.innerHTML = '<div class="no-results">Erreur lors du chargement</div>'; if (container) {
container.innerHTML = `
<div class="no-results">
<p>❌ Erreur lors du chargement des séries</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
<button class="btn-secondary btn-small" onclick="loadSeriesReleases()" style="margin-top: 10px;">
🔄 Réessayer
</button>
</div>`;
}
} }
} }
@@ -184,6 +376,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} else if (tabName === 'series') { } else if (tabName === 'series') {
if (!window.seriesTabLoaded) { if (!window.seriesTabLoaded) {
loadSeriesRecommendations();
loadSeriesReleases(); loadSeriesReleases();
window.seriesTabLoaded = true; window.seriesTabLoaded = true;
} }
@@ -200,6 +393,8 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
// Make functions available globally // Make functions available globally
window.loadSeriesEpisodes = loadSeriesEpisodes;
window.loadSeriesRecommendations = loadSeriesRecommendations;
window.loadAnimeReleases = loadAnimeReleases; window.loadAnimeReleases = loadAnimeReleases;
window.loadSeriesReleases = loadSeriesReleases; window.loadSeriesReleases = loadSeriesReleases;
window.loadProvidersGrid = loadProvidersGrid; window.loadProvidersGrid = loadProvidersGrid;
+1
View File
@@ -14,6 +14,7 @@
<script src="/static/js/downloads.js?v=1.5" defer></script> <script src="/static/js/downloads.js?v=1.5" defer></script>
<script src="/static/js/anime.js?v=1.5" defer></script> <script src="/static/js/anime.js?v=1.5" defer></script>
<script src="/static/js/anime-details.js?v=1.5" defer></script> <script src="/static/js/anime-details.js?v=1.5" defer></script>
<script src="/static/js/series-search.js?v=1.5" defer></script>
<script src="/static/js/recommendations.js?v=1.5" defer></script> <script src="/static/js/recommendations.js?v=1.5" defer></script>
<script src="/static/js/tabs.js?v=1.5" defer></script> <script src="/static/js/tabs.js?v=1.5" defer></script>
<script src="/static/js/main.js?v=1.5" defer></script> <script src="/static/js/main.js?v=1.5" defer></script>
@@ -1,32 +0,0 @@
{# Template pour un onglet de provider anime spécifique #}
{# Variables disponibles: provider_id, provider_info #}
<div id="tab-anime-{{ provider_id }}" class="tab-content">
<div class="url-form">
<div class="anime-input-group">
<input
type="text"
id="searchInput-{{ provider_id }}"
placeholder="Rechercher un anime sur {{ provider_info.name }}..."
onkeypress="if(event.key === 'Enter') searchAnimeProvider('{{ provider_id }}')"
>
<select id="langSelect-{{ provider_id }}" style="max-width: 120px;">
<option value="vostfr">VOSTFR</option>
<option value="vf">VF</option>
</select>
<button type="button" class="btn-primary" onclick="searchAnimeProvider('{{ provider_id }}')">
<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>
Rechercher
</button>
</div>
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 13px; color: #888;">
<input type="checkbox" id="includeMetadata-{{ provider_id }}" style="width: auto; margin: 0;">
<label for="includeMetadata-{{ provider_id }}" style="cursor: pointer; user-select: none;">
📊 Inclure les métadonnées
</label>
</div>
</div>
<div id="searchResults-{{ provider_id }}" class="search-results"></div>
</div>
-28
View File
@@ -1,28 +0,0 @@
<!-- Direct Download Tab -->
<div id="tab-direct" class="tab-content">
<div class="url-form">
<form id="downloadForm">
<div class="input-group">
<input
type="text"
id="urlInput"
placeholder="Collez le lien de téléchargement ici..."
required
>
<button type="submit" class="btn-primary">
<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>
</form>
<div class="supported-hosts">
<span class="host-badge">1fichier</span>
<span class="host-badge">Doodstream</span>
<span class="host-badge">Rapidfile</span>
<span class="host-badge">Anime-Sama</span>
<span class="host-badge">Anime-Ultime</span>
</div>
</div>
</div>
-6
View File
@@ -9,12 +9,6 @@
</svg> </svg>
Accueil Accueil
</button> </button>
<button class="tab" data-tab-type="search" onclick="switchTab('search')">
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" 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>
Recherche
</button>
<button class="tab" data-tab-type="anime" onclick="switchTab('anime')"> <button class="tab" data-tab-type="anime" onclick="switchTab('anime')">
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
-24
View File
@@ -1,24 +0,0 @@
<!-- Search Tab -->
<div id="tab-search" class="tab-content">
<div class="url-form">
<div class="input-group">
<input
type="text"
id="searchInput"
placeholder="Rechercher un anime (ex: Frieren, One Piece...)"
>
<button type="button" class="btn-primary" onclick="handleSearch()">
<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>
Rechercher
</button>
</div>
<div style="margin-top: 10px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; font-size: 12px; color: #aaa;">
💡 <strong>Info:</strong> La recherche utilise MyAnimeList pour afficher la fiche complète de l'anime (synopsis, saisons, etc.) et trouve les sources de streaming disponibles.
</div>
</div>
<!-- Anime details and streaming results -->
<div id="animeSearchResults"></div>
</div>
+80 -16
View File
@@ -5,37 +5,101 @@
{% include "components/home_section.html" %} {% include "components/home_section.html" %}
{% include "components/search_tab.html" %}
<!-- Nouveaux onglets --> <!-- Nouveaux onglets -->
<div id="tab-anime" class="tab-content"> <div id="tab-anime" class="tab-content">
<!-- Anime Search Section -->
<div class="section-header"> <div class="section-header">
<h2>🎬 Anime</h2> <h2>🎬 Rechercher un Anime</h2>
<div style="display:flex; gap:10px;"> </div>
<button class="btn-small btn-secondary" onclick="loadAnimeReleases()"> <div class="url-form">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;"> <div class="input-group">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> <input
type="text"
id="animeSearchInput"
placeholder="Rechercher un anime (ex: Frieren, One Piece, Naruto...)"
>
<button type="button" class="btn-primary" onclick="handleAnimeSearch()">
<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> </svg>
Dernières sorties Rechercher
</button> </button>
</div> </div>
<div style="margin-top: 10px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; font-size: 12px; color: #aaa;">
💡 <strong>Info:</strong> La recherche utilise MyAnimeList pour afficher la fiche complète (synopsis, saisons, etc.)
</div>
</div> </div>
<div id="animeReleasesList" class="recommendations-carousel" style="margin-bottom: 40px;"></div>
<!-- Anime search results -->
<div id="animeSearchResults" style="margin-bottom: 40px;"></div>
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.1); margin: 40px 0;">
<!-- Latest Releases Section -->
<div class="section-header">
<h2>🔥 Dernières sorties Anime</h2>
<button class="btn-small btn-secondary" onclick="loadAnimeReleases()">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Dernières sorties
</button>
</div>
<div id="animeReleasesList" class="recommendations-carousel"></div>
</div> </div>
<div id="tab-series" class="tab-content"> <div id="tab-series" class="tab-content">
<!-- Series Search Section -->
<div class="section-header"> <div class="section-header">
<h2>📺 Séries TV</h2> <h2>📺 Rechercher une Série TV</h2>
<div style="display:flex; gap:10px;"> </div>
<button class="btn-small btn-secondary" onclick="loadSeriesReleases()"> <div class="url-form">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;"> <div class="input-group">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> <input
type="text"
id="seriesSearchInput"
placeholder="Rechercher une série (ex: Breaking Bad, Game of Thrones...)"
>
<button type="button" class="btn-primary" onclick="handleSeriesSearch()">
<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> </svg>
Dernières sorties Rechercher
</button> </button>
</div> </div>
<div style="margin-top: 10px; padding: 10px; background: rgba(255, 107, 107, 0.1); border-radius: 8px; font-size: 12px; color: #aaa;">
💡 <strong>Info:</strong> La recherche utilise FS7 pour trouver des séries TV américaines et européennes
</div>
</div> </div>
<div id="seriesReleasesList" class="releases-carousel" style="margin-bottom: 40px;"></div>
<!-- Series search results -->
<div id="seriesSearchResults" style="margin-bottom: 40px;"></div>
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.1); margin: 40px 0;">
<!-- Recommendations Section -->
<div class="section-header">
<h2>🎯 Recommandé pour vous</h2>
<button class="btn-small btn-secondary" onclick="loadSeriesRecommendations()">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Actualiser
</button>
</div>
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;"></div>
<!-- Latest Releases Section -->
<div class="section-header" style="margin-top: 40px;">
<h2>🔥 Dernières sorties Séries TV</h2>
<button class="btn-small btn-secondary" onclick="loadSeriesReleases()">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Dernières sorties
</button>
</div>
<div id="seriesReleasesList" class="releases-carousel"></div>
</div> </div>
<div id="tab-providers" class="tab-content"> <div id="tab-providers" class="tab-content">