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:
+30
-1
@@ -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}")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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
|
# Method 2: Look for source in <source> tag
|
||||||
source_tag = soup.find('source')
|
source_tag = soup.find('source')
|
||||||
if source_tag and source_tag.get('src'):
|
if source_tag and source_tag.get('src'):
|
||||||
download_url = source_tag['src']
|
|
||||||
logger.info(f"Found video source from <source> tag")
|
logger.info(f"Found video source from <source> tag")
|
||||||
else:
|
return source_tag['src']
|
||||||
# Method 3: Look for video URL in JavaScript
|
|
||||||
# Vidzy often stores the video URL in a JavaScript variable
|
# Method 3: Search entire HTML for .m3u8 URLs (Vidzy uses HLS)
|
||||||
scripts = soup.find_all('script')
|
html_patterns = [
|
||||||
for script in scripts:
|
r'(https?://[^\s<>"\'`]+\.m3u8[^\s<>"\'`]*)',
|
||||||
if script.string:
|
r'(https?://[^\s<>"\'`]+/master[^\s<>"\'`]*)',
|
||||||
# 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():
|
for pattern in html_patterns:
|
||||||
raise ValueError("Could not find video URL in page")
|
matches = re.findall(pattern, html)
|
||||||
|
if matches:
|
||||||
|
# Filter out obvious false positives
|
||||||
|
for match in matches:
|
||||||
|
# Accept URLs with 'master' or from video hosts
|
||||||
|
if 'master' in match.lower() or any(host in match for host in ['hls', 'video', 'stream']):
|
||||||
|
logger.info(f"Found video URL in HTML: {match[:100]}...")
|
||||||
|
return match
|
||||||
|
|
||||||
# Ensure URL is absolute
|
logger.warning("Static parsing failed to find video URL")
|
||||||
if not download_url.startswith('http'):
|
return None
|
||||||
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:
|
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)}")
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
+203
-8
@@ -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 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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.releases && data.releases.length > 0) {
|
// Collect results from all providers, especially fs7
|
||||||
container.innerHTML = `<div class="releases-carousel">${data.releases.map(anime =>
|
if (data.results) {
|
||||||
renderReleaseCard({...anime, title: anime.title + ' [Série]'})
|
// 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;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
+74
-10
@@ -5,13 +5,39 @@
|
|||||||
|
|
||||||
{% 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>
|
||||||
|
<div class="url-form">
|
||||||
|
<div class="input-group">
|
||||||
|
<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>
|
||||||
|
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 (synopsis, saisons, etc.)
|
||||||
|
</div>
|
||||||
|
</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()">
|
<button class="btn-small btn-secondary" onclick="loadAnimeReleases()">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
<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>
|
<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>
|
||||||
@@ -19,14 +45,53 @@
|
|||||||
Dernières sorties
|
Dernières sorties
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div id="animeReleasesList" class="recommendations-carousel"></div>
|
||||||
<div id="animeReleasesList" class="recommendations-carousel" style="margin-bottom: 40px;"></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>
|
||||||
|
<div class="url-form">
|
||||||
|
<div class="input-group">
|
||||||
|
<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>
|
||||||
|
Rechercher
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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()">
|
<button class="btn-small btn-secondary" onclick="loadSeriesReleases()">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
<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>
|
<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>
|
||||||
@@ -34,8 +99,7 @@
|
|||||||
Dernières sorties
|
Dernières sorties
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div id="seriesReleasesList" class="releases-carousel"></div>
|
||||||
<div id="seriesReleasesList" class="releases-carousel" style="margin-bottom: 40px;"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-providers" class="tab-content">
|
<div id="tab-providers" class="tab-content">
|
||||||
|
|||||||
Reference in New Issue
Block a user