feat: Add anime metadata extraction and fix episode selection bug
Features: - Added rich metadata extraction for all anime providers (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree) - New AnimeMetadata model with synopsis, genres, rating, release year, studio, poster/banner images, episode count, and status - New /api/anime/metadata endpoint for fetching metadata of specific anime - Enhanced /api/anime/search endpoint with optional include_metadata parameter - Updated web interface with metadata display (expandable synopsis, genres, rating, year) - Added metadata toggle checkbox in search UI (disabled by default for performance) Bug Fixes: - Fixed episode selection bug where select would reset to default after any change - Removed onchange event from select element that was causing unwanted reloads - Fixed download button disappearing after episode download - Episodes can now be downloaded multiple times without page refresh Enhancements: - Metadata displayed with icons (📅 year, ⭐ rating, 🏷️ genres, 📺 episodes, 📡 status) - Expandable synopsis section for detailed descriptions - Better visual organization of anime information - Maintains backward compatibility (metadata is optional) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -346,11 +346,188 @@ class AnimeSamaDownloader(BaseDownloader):
|
|||||||
filename = f"{anime_name} - Episode {episode}.mp4"
|
filename = f"{anime_name} - Episode {episode}.mp4"
|
||||||
return filename.title()
|
return filename.title()
|
||||||
|
|
||||||
async def search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
|
async def get_anime_metadata(self, anime_url: str) -> dict:
|
||||||
|
"""
|
||||||
|
Extract rich metadata from anime page
|
||||||
|
Returns synopsis, genres, rating, release year, studio, etc.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print(f"[ANIME-SAMA] Extracting metadata from: {anime_url}")
|
||||||
|
response = await self.client.get(anime_url)
|
||||||
|
soup = BeautifulSoup(response.text, 'lxml')
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'synopsis': None,
|
||||||
|
'genres': [],
|
||||||
|
'rating': None,
|
||||||
|
'release_year': None,
|
||||||
|
'studio': None,
|
||||||
|
'poster_image': None,
|
||||||
|
'banner_image': None,
|
||||||
|
'total_episodes': None,
|
||||||
|
'status': None,
|
||||||
|
'alternative_titles': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract synopsis
|
||||||
|
# Anime-Sama typically has synopsis in a div with specific classes
|
||||||
|
synopsis_selectors = [
|
||||||
|
'div.synopsis',
|
||||||
|
'div.description',
|
||||||
|
'div[class*="synopsis"]',
|
||||||
|
'div[class*="description"]',
|
||||||
|
'p.synopsis',
|
||||||
|
'div.texte',
|
||||||
|
'.asn-synopsis'
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in synopsis_selectors:
|
||||||
|
synopsis_elem = soup.select_one(selector)
|
||||||
|
if synopsis_elem:
|
||||||
|
synopsis = synopsis_elem.get_text(strip=True)
|
||||||
|
if len(synopsis) > 50: # Ensure it's actual content
|
||||||
|
metadata['synopsis'] = synopsis
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract genres
|
||||||
|
# Look for genre tags/links
|
||||||
|
genre_patterns = [
|
||||||
|
r'Genre?\s*:?\s*([^\n]+)',
|
||||||
|
r'Type?\s*:?\s*([^\n]+)',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Try to find genre links
|
||||||
|
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I))
|
||||||
|
if genre_links:
|
||||||
|
metadata['genres'] = [link.get_text(strip=True) for link in genre_links[:5]]
|
||||||
|
|
||||||
|
# Also try to find genres in text
|
||||||
|
page_text = soup.get_text()
|
||||||
|
for pattern in genre_patterns:
|
||||||
|
match = re.search(pattern, page_text, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
genres_text = match.group(1)
|
||||||
|
# Split by common separators
|
||||||
|
genres = [g.strip() for g in re.split(r'[,;/|]', genres_text)]
|
||||||
|
genres = [g for g in genres if g and len(g) > 2]
|
||||||
|
if genres:
|
||||||
|
metadata['genres'].extend(genres)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Remove duplicates
|
||||||
|
metadata['genres'] = list(set(metadata['genres']))
|
||||||
|
|
||||||
|
# Extract rating
|
||||||
|
rating_selectors = [
|
||||||
|
'span.rating',
|
||||||
|
'div.rating',
|
||||||
|
'span.score',
|
||||||
|
'div[class*="rating"]',
|
||||||
|
'div[class*="score"]',
|
||||||
|
'.asn-rating'
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in rating_selectors:
|
||||||
|
rating_elem = soup.select_one(selector)
|
||||||
|
if rating_elem:
|
||||||
|
rating_text = rating_elem.get_text(strip=True)
|
||||||
|
# Look for rating patterns like "8.5/10", "4/5", "★★★★☆"
|
||||||
|
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
|
||||||
|
if rating_match:
|
||||||
|
metadata['rating'] = f"{rating_match.group(1)}/10"
|
||||||
|
break
|
||||||
|
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*5', rating_text)
|
||||||
|
if rating_match:
|
||||||
|
rating_val = float(rating_match.group(1)) * 2 # Convert to /10
|
||||||
|
metadata['rating'] = f"{rating_val:.1f}/10"
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract release year
|
||||||
|
year_patterns = [
|
||||||
|
r'(\d{4})',
|
||||||
|
r'Année?\s*:?\s*(\d{4})',
|
||||||
|
r'Year?\s*:?\s*(\d{4})',
|
||||||
|
r'Sortie?\s*:?\s*(\d{4})',
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in year_patterns:
|
||||||
|
matches = re.findall(pattern, page_text)
|
||||||
|
# Filter valid years (between 1950 and current year + 2)
|
||||||
|
import datetime
|
||||||
|
current_year = datetime.datetime.now().year + 2
|
||||||
|
valid_years = [int(m) for m in matches if 1950 <= int(m) <= current_year]
|
||||||
|
if valid_years:
|
||||||
|
# Take the most common year (likely the release year)
|
||||||
|
from collections import Counter
|
||||||
|
metadata['release_year'] = Counter(valid_years).most_common(1)[0][0]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract studio
|
||||||
|
studio_patterns = [
|
||||||
|
r'Studio\s*:?\s*([^\n,]+)',
|
||||||
|
r'Produit\s*par\s*:?\s*([^\n,]+)',
|
||||||
|
r'Animation\s*:?\s*([^\n,]+)',
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in studio_patterns:
|
||||||
|
match = re.search(pattern, page_text, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
studio = match.group(1).strip()
|
||||||
|
if len(studio) > 2 and len(studio) < 100:
|
||||||
|
metadata['studio'] = studio
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract poster image
|
||||||
|
poster_elem = soup.select_one('img.poster, img.cover, img[class*="poster"], img[class*="cover"], .asn-poster img')
|
||||||
|
if poster_elem:
|
||||||
|
metadata['poster_image'] = poster_elem.get('src') or poster_elem.get('data-src')
|
||||||
|
|
||||||
|
# Extract banner image
|
||||||
|
banner_elem = soup.select_one('div.banner img, .asn-banner img, img[class*="banner"]')
|
||||||
|
if banner_elem:
|
||||||
|
metadata['banner_image'] = banner_elem.get('src') or banner_elem.get('data-src')
|
||||||
|
|
||||||
|
# Extract total episodes
|
||||||
|
episodes_count = len(await self.get_episodes(anime_url))
|
||||||
|
if episodes_count > 0:
|
||||||
|
metadata['total_episodes'] = episodes_count
|
||||||
|
|
||||||
|
# Extract status (ongoing/completed)
|
||||||
|
status_patterns = [
|
||||||
|
r'En\s*cours',
|
||||||
|
r'Ongoing',
|
||||||
|
r'Terminé',
|
||||||
|
r'Completed',
|
||||||
|
r'Finished',
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in status_patterns:
|
||||||
|
if re.search(pattern, page_text, re.IGNORECASE):
|
||||||
|
if 'cour' in pattern.lower() or 'ongoing' in pattern.lower():
|
||||||
|
metadata['status'] = 'Ongoing'
|
||||||
|
else:
|
||||||
|
metadata['status'] = 'Completed'
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"[ANIME-SAMA] Extracted metadata: {metadata}")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ANIME-SAMA] Error extracting metadata: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Search for anime on anime-sama
|
Search for anime on anime-sama
|
||||||
Returns list of anime with title, url, and cover image
|
Returns list of anime with title, url, and cover image
|
||||||
Uses the official Anime-Sama search API which handles typos and fuzzy matching
|
Uses the official Anime-Sama search API which handles typos and fuzzy matching
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
lang: Language preference (vostfr, vf)
|
||||||
|
include_metadata: Whether to fetch full metadata for each result (slower)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Update domains before searching to ensure we have the current domain
|
# Update domains before searching to ensure we have the current domain
|
||||||
@@ -395,12 +572,20 @@ class AnimeSamaDownloader(BaseDownloader):
|
|||||||
if '/saison1/' not in href:
|
if '/saison1/' not in href:
|
||||||
href = href.rstrip('/') + f'/saison1/{lang}/'
|
href = href.rstrip('/') + f'/saison1/{lang}/'
|
||||||
|
|
||||||
results.append({
|
result = {
|
||||||
'title': title,
|
'title': title,
|
||||||
'url': href,
|
'url': href,
|
||||||
'cover_image': cover_image,
|
'cover_image': cover_image,
|
||||||
'type': 'search_result'
|
'type': 'search_result',
|
||||||
})
|
'metadata': None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch metadata if requested
|
||||||
|
if include_metadata:
|
||||||
|
metadata = await self.get_anime_metadata(href)
|
||||||
|
result['metadata'] = metadata
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
print(f"[ANIME-SAMA] Found {len(results)} results")
|
print(f"[ANIME-SAMA] Found {len(results)} results")
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -165,10 +165,124 @@ class AnimeUltimeDownloader(BaseDownloader):
|
|||||||
filename = f"{anime_name} - Episode {episode}.mp4"
|
filename = f"{anime_name} - Episode {episode}.mp4"
|
||||||
return filename.title()
|
return filename.title()
|
||||||
|
|
||||||
async def search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
|
async def get_anime_metadata(self, anime_url: str) -> dict:
|
||||||
|
"""
|
||||||
|
Extract rich metadata from anime page
|
||||||
|
Returns synopsis, genres, rating, release year, studio, etc.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}")
|
||||||
|
response = await self.client.get(anime_url)
|
||||||
|
soup = BeautifulSoup(response.text, 'lxml')
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'synopsis': None,
|
||||||
|
'genres': [],
|
||||||
|
'rating': None,
|
||||||
|
'release_year': None,
|
||||||
|
'studio': None,
|
||||||
|
'poster_image': None,
|
||||||
|
'banner_image': None,
|
||||||
|
'total_episodes': None,
|
||||||
|
'status': None,
|
||||||
|
'alternative_titles': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract synopsis
|
||||||
|
synopsis_selectors = [
|
||||||
|
'div.synopsis',
|
||||||
|
'div.description',
|
||||||
|
'div[class*="synopsis"]',
|
||||||
|
'div[class*="synopsis"]',
|
||||||
|
'p.synopsis',
|
||||||
|
'.info',
|
||||||
|
'div.texte'
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in synopsis_selectors:
|
||||||
|
synopsis_elem = soup.select_one(selector)
|
||||||
|
if synopsis_elem:
|
||||||
|
synopsis = synopsis_elem.get_text(strip=True)
|
||||||
|
if len(synopsis) > 50:
|
||||||
|
metadata['synopsis'] = synopsis
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract genres from meta tags and page content
|
||||||
|
page_text = soup.get_text()
|
||||||
|
|
||||||
|
# Look for genre in meta tags
|
||||||
|
genre_meta = soup.find('meta', property='genre') or soup.find('meta', attrs={'name': 'genre'})
|
||||||
|
if genre_meta:
|
||||||
|
genres_text = genre_meta.get('content', '')
|
||||||
|
if genres_text:
|
||||||
|
metadata['genres'] = [g.strip() for g in genres_text.split(',')]
|
||||||
|
|
||||||
|
# Try to find genre links
|
||||||
|
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type|cat', re.I))
|
||||||
|
if genre_links:
|
||||||
|
for link in genre_links[:5]:
|
||||||
|
genre = link.get_text(strip=True)
|
||||||
|
if genre and genre not in metadata['genres']:
|
||||||
|
metadata['genres'].append(genre)
|
||||||
|
|
||||||
|
# Extract rating
|
||||||
|
rating_selectors = [
|
||||||
|
'span.rating',
|
||||||
|
'div.rating',
|
||||||
|
'span.score',
|
||||||
|
'div.note',
|
||||||
|
'.rating'
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in rating_selectors:
|
||||||
|
rating_elem = soup.select_one(selector)
|
||||||
|
if rating_elem:
|
||||||
|
rating_text = rating_elem.get_text(strip=True)
|
||||||
|
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
|
||||||
|
if rating_match:
|
||||||
|
metadata['rating'] = f"{rating_match.group(1)}/10"
|
||||||
|
break
|
||||||
|
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*5', rating_text)
|
||||||
|
if rating_match:
|
||||||
|
rating_val = float(rating_match.group(1)) * 2
|
||||||
|
metadata['rating'] = f"{rating_val:.1f}/10"
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract release year
|
||||||
|
year_match = re.search(r'\b(19\d{2}|20\d{2})\b', page_text)
|
||||||
|
if year_match:
|
||||||
|
import datetime
|
||||||
|
current_year = datetime.datetime.now().year + 2
|
||||||
|
year = int(year_match.group(1))
|
||||||
|
if 1950 <= year <= current_year:
|
||||||
|
metadata['release_year'] = year
|
||||||
|
|
||||||
|
# Extract poster image from og:image
|
||||||
|
og_image = soup.find('meta', property='og:image')
|
||||||
|
if og_image:
|
||||||
|
metadata['poster_image'] = og_image.get('content')
|
||||||
|
|
||||||
|
# Extract total episodes
|
||||||
|
episodes_count = len(await self.get_episodes(anime_url))
|
||||||
|
if episodes_count > 0:
|
||||||
|
metadata['total_episodes'] = episodes_count
|
||||||
|
|
||||||
|
print(f"[ANIME-ULTIME] Extracted metadata: {metadata}")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ANIME-ULTIME] Error extracting metadata: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Search for anime on anime-ultime
|
Search for anime on anime-ultime
|
||||||
Returns list of anime with title, url, and cover image
|
Returns list of anime with title, url, and cover image
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
lang: Language preference (vostfr, vf)
|
||||||
|
include_metadata: Whether to fetch full metadata for each result (slower)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import time
|
import time
|
||||||
@@ -231,11 +345,19 @@ class AnimeUltimeDownloader(BaseDownloader):
|
|||||||
if not href.startswith('http'):
|
if not href.startswith('http'):
|
||||||
href = urljoin("https://www.anime-ultime.net/", href)
|
href = urljoin("https://www.anime-ultime.net/", href)
|
||||||
|
|
||||||
results.append({
|
result_item = {
|
||||||
'title': better_title,
|
'title': better_title,
|
||||||
'url': href,
|
'url': href,
|
||||||
'type': 'search_result'
|
'type': 'search_result',
|
||||||
})
|
'metadata': None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch metadata if requested
|
||||||
|
if include_metadata:
|
||||||
|
metadata = await self.get_anime_metadata(href)
|
||||||
|
result_item['metadata'] = metadata
|
||||||
|
|
||||||
|
results.append(result_item)
|
||||||
|
|
||||||
print(f"[ANIME-ULTIME] Found {len(results)} results")
|
print(f"[ANIME-ULTIME] Found {len(results)} results")
|
||||||
return results
|
return results
|
||||||
|
|||||||
+109
-4
@@ -111,9 +111,107 @@ class NekoSamaDownloader(BaseDownloader):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
|
async def get_anime_metadata(self, anime_url: str) -> dict:
|
||||||
|
"""
|
||||||
|
Extract rich metadata from anime page
|
||||||
|
Returns synopsis, genres, rating, release year, studio, etc.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
|
||||||
|
response = await self.client.get(anime_url)
|
||||||
|
soup = BeautifulSoup(response.text, 'lxml')
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'synopsis': None,
|
||||||
|
'genres': [],
|
||||||
|
'rating': None,
|
||||||
|
'release_year': None,
|
||||||
|
'studio': None,
|
||||||
|
'poster_image': None,
|
||||||
|
'banner_image': None,
|
||||||
|
'total_episodes': None,
|
||||||
|
'status': None,
|
||||||
|
'alternative_titles': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract synopsis
|
||||||
|
synopsis_selectors = [
|
||||||
|
'div.synopsis',
|
||||||
|
'div.description',
|
||||||
|
'div[class*="synopsis"]',
|
||||||
|
'div[class*="desc"]',
|
||||||
|
'p.synopsis',
|
||||||
|
'.anime-synopsis',
|
||||||
|
'.summary'
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in synopsis_selectors:
|
||||||
|
synopsis_elem = soup.select_one(selector)
|
||||||
|
if synopsis_elem:
|
||||||
|
synopsis = synopsis_elem.get_text(strip=True)
|
||||||
|
if len(synopsis) > 50:
|
||||||
|
metadata['synopsis'] = synopsis
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract genres
|
||||||
|
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I))
|
||||||
|
if genre_links:
|
||||||
|
metadata['genres'] = [link.get_text(strip=True) for link in genre_links[:5]]
|
||||||
|
|
||||||
|
# Extract rating
|
||||||
|
rating_selectors = [
|
||||||
|
'span.rating',
|
||||||
|
'div.rating',
|
||||||
|
'span.score',
|
||||||
|
'div[class*="rating"]',
|
||||||
|
'div[class*="score"]'
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in rating_selectors:
|
||||||
|
rating_elem = soup.select_one(selector)
|
||||||
|
if rating_elem:
|
||||||
|
rating_text = rating_elem.get_text(strip=True)
|
||||||
|
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
|
||||||
|
if rating_match:
|
||||||
|
metadata['rating'] = f"{rating_match.group(1)}/10"
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract release year
|
||||||
|
page_text = soup.get_text()
|
||||||
|
year_matches = re.findall(r'\b(19\d{2}|20\d{2})\b', page_text)
|
||||||
|
if year_matches:
|
||||||
|
import datetime
|
||||||
|
current_year = datetime.datetime.now().year + 2
|
||||||
|
valid_years = [int(y) for y in year_matches if 1950 <= int(y) <= current_year]
|
||||||
|
if valid_years:
|
||||||
|
from collections import Counter
|
||||||
|
metadata['release_year'] = Counter(valid_years).most_common(1)[0][0]
|
||||||
|
|
||||||
|
# Extract poster image
|
||||||
|
poster_elem = soup.select_one('img.poster, img.cover, .anime-poster img')
|
||||||
|
if poster_elem:
|
||||||
|
metadata['poster_image'] = poster_elem.get('src') or poster_elem.get('data-src')
|
||||||
|
|
||||||
|
# Extract total episodes
|
||||||
|
episodes_count = len(await self.get_episodes(anime_url))
|
||||||
|
if episodes_count > 0:
|
||||||
|
metadata['total_episodes'] = episodes_count
|
||||||
|
|
||||||
|
print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[NEKO-SAMA] Error extracting metadata: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Search for anime on neko-sama
|
Search for anime on neko-sama
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
lang: Language preference (vostfr, vf)
|
||||||
|
include_metadata: Whether to fetch full metadata for each result (slower)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import time
|
import time
|
||||||
@@ -130,11 +228,18 @@ class NekoSamaDownloader(BaseDownloader):
|
|||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
print(f"[NEKO-SAMA] Found anime at {str(response.url)}")
|
print(f"[NEKO-SAMA] Found anime at {str(response.url)}")
|
||||||
return [{
|
result = {
|
||||||
'title': query,
|
'title': query,
|
||||||
'url': str(response.url),
|
'url': str(response.url),
|
||||||
'type': 'direct'
|
'type': 'direct',
|
||||||
}]
|
'metadata': None
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_metadata:
|
||||||
|
metadata = await self.get_anime_metadata(str(response.url))
|
||||||
|
result['metadata'] = metadata
|
||||||
|
|
||||||
|
return [result]
|
||||||
|
|
||||||
print(f"[NEKO-SAMA] No anime found")
|
print(f"[NEKO-SAMA] No anime found")
|
||||||
return []
|
return []
|
||||||
|
|||||||
+113
-4
@@ -111,9 +111,111 @@ class VostfreeDownloader(BaseDownloader):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
|
async def get_anime_metadata(self, anime_url: str) -> dict:
|
||||||
|
"""
|
||||||
|
Extract rich metadata from anime page
|
||||||
|
Returns synopsis, genres, rating, release year, studio, etc.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print(f"[VOSTFREE] Extracting metadata from: {anime_url}")
|
||||||
|
response = await self.client.get(anime_url)
|
||||||
|
soup = BeautifulSoup(response.text, 'lxml')
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'synopsis': None,
|
||||||
|
'genres': [],
|
||||||
|
'rating': None,
|
||||||
|
'release_year': None,
|
||||||
|
'studio': None,
|
||||||
|
'poster_image': None,
|
||||||
|
'banner_image': None,
|
||||||
|
'total_episodes': None,
|
||||||
|
'status': None,
|
||||||
|
'alternative_titles': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract synopsis
|
||||||
|
synopsis_selectors = [
|
||||||
|
'div.synopsis',
|
||||||
|
'div.description',
|
||||||
|
'div[class*="synopsis"]',
|
||||||
|
'div[class*="desc"]',
|
||||||
|
'p.synopsis',
|
||||||
|
'.anime-synopsis'
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in synopsis_selectors:
|
||||||
|
synopsis_elem = soup.select_one(selector)
|
||||||
|
if synopsis_elem:
|
||||||
|
synopsis = synopsis_elem.get_text(strip=True)
|
||||||
|
if len(synopsis) > 50:
|
||||||
|
metadata['synopsis'] = synopsis
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract genres
|
||||||
|
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I))
|
||||||
|
if genre_links:
|
||||||
|
metadata['genres'] = [link.get_text(strip=True) for link in genre_links[:5]]
|
||||||
|
|
||||||
|
# Extract rating
|
||||||
|
rating_selectors = [
|
||||||
|
'span.rating',
|
||||||
|
'div.rating',
|
||||||
|
'span.score',
|
||||||
|
'div[class*="rating"]',
|
||||||
|
'div[class*="score"]'
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in rating_selectors:
|
||||||
|
rating_elem = soup.select_one(selector)
|
||||||
|
if rating_elem:
|
||||||
|
rating_text = rating_elem.get_text(strip=True)
|
||||||
|
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
|
||||||
|
if rating_match:
|
||||||
|
metadata['rating'] = f"{rating_match.group(1)}/10"
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract release year
|
||||||
|
page_text = soup.get_text()
|
||||||
|
year_matches = re.findall(r'\b(19\d{2}|20\d{2})\b', page_text)
|
||||||
|
if year_matches:
|
||||||
|
import datetime
|
||||||
|
current_year = datetime.datetime.now().year + 2
|
||||||
|
valid_years = [int(y) for y in year_matches if 1950 <= int(y) <= current_year]
|
||||||
|
if valid_years:
|
||||||
|
from collections import Counter
|
||||||
|
metadata['release_year'] = Counter(valid_years).most_common(1)[0][0]
|
||||||
|
|
||||||
|
# Extract poster image
|
||||||
|
poster_elem = soup.select_one('img.poster, img.cover, .anime-poster img')
|
||||||
|
if poster_elem:
|
||||||
|
metadata['poster_image'] = poster_elem.get('src') or poster_elem.get('data-src')
|
||||||
|
|
||||||
|
# Extract poster from og:image
|
||||||
|
og_image = soup.find('meta', property='og:image')
|
||||||
|
if og_image and not metadata['poster_image']:
|
||||||
|
metadata['poster_image'] = og_image.get('content')
|
||||||
|
|
||||||
|
# Extract total episodes
|
||||||
|
episodes_count = len(await self.get_episodes(anime_url))
|
||||||
|
if episodes_count > 0:
|
||||||
|
metadata['total_episodes'] = episodes_count
|
||||||
|
|
||||||
|
print(f"[VOSTFREE] Extracted metadata: {metadata}")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[VOSTFREE] Error extracting metadata: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Search for anime on vostfree
|
Search for anime on vostfree
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
lang: Language preference (vostfr, vf)
|
||||||
|
include_metadata: Whether to fetch full metadata for each result (slower)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import time
|
import time
|
||||||
@@ -130,11 +232,18 @@ class VostfreeDownloader(BaseDownloader):
|
|||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
print(f"[VOSTFREE] Found anime at {str(response.url)}")
|
print(f"[VOSTFREE] Found anime at {str(response.url)}")
|
||||||
return [{
|
result = {
|
||||||
'title': query,
|
'title': query,
|
||||||
'url': str(response.url),
|
'url': str(response.url),
|
||||||
'type': 'direct'
|
'type': 'direct',
|
||||||
}]
|
'metadata': None
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_metadata:
|
||||||
|
metadata = await self.get_anime_metadata(str(response.url))
|
||||||
|
result['metadata'] = metadata
|
||||||
|
|
||||||
|
return [result]
|
||||||
|
|
||||||
print(f"[VOSTFREE] No anime found")
|
print(f"[VOSTFREE] No anime found")
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -40,3 +40,26 @@ class DownloadTask(BaseModel):
|
|||||||
class DownloadRequest(BaseModel):
|
class DownloadRequest(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
filename: Optional[str] = None
|
filename: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeMetadata(BaseModel):
|
||||||
|
"""Metadata for anime series"""
|
||||||
|
synopsis: Optional[str] = None
|
||||||
|
genres: list[str] = []
|
||||||
|
rating: Optional[str] = None # Could be "PG-13", "R", etc., or numeric like "8.5/10"
|
||||||
|
release_year: Optional[int] = None
|
||||||
|
studio: Optional[str] = None
|
||||||
|
poster_image: Optional[str] = None
|
||||||
|
banner_image: Optional[str] = None
|
||||||
|
total_episodes: Optional[int] = None
|
||||||
|
status: Optional[str] = None # "Ongoing", "Completed", etc.
|
||||||
|
alternative_titles: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeSearchResult(BaseModel):
|
||||||
|
"""Enhanced search result with metadata"""
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
cover_image: Optional[str] = None
|
||||||
|
type: str # "search_result" or "direct"
|
||||||
|
metadata: Optional[AnimeMetadata] = None
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ async def root():
|
|||||||
return {
|
return {
|
||||||
"message": "Ohm Stream Downloader API",
|
"message": "Ohm Stream Downloader API",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"version": "2.0",
|
"version": "2.1",
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
"POST /api/download": "Start a new download",
|
"POST /api/download": "Start a new download",
|
||||||
"GET /api/downloads": "List all downloads",
|
"GET /api/downloads": "List all downloads",
|
||||||
@@ -51,6 +51,10 @@ async def root():
|
|||||||
"POST /api/download/{task_id}/resume": "Resume a download",
|
"POST /api/download/{task_id}/resume": "Resume a download",
|
||||||
"DELETE /api/download/{task_id}": "Cancel a download",
|
"DELETE /api/download/{task_id}": "Cancel a download",
|
||||||
"GET /api/providers": "List all supported providers",
|
"GET /api/providers": "List all supported providers",
|
||||||
|
"GET /api/anime/search": "Search anime across all providers",
|
||||||
|
"GET /api/anime/metadata": "Get detailed anime metadata (synopsis, genres, rating, etc.)",
|
||||||
|
"GET /api/anime/episodes": "Get episode list for an anime",
|
||||||
|
"POST /api/anime/download-season": "Download all episodes of a season",
|
||||||
"GET /web": "Web interface"
|
"GET /web": "Web interface"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,14 +160,21 @@ async def download_file(task_id: str):
|
|||||||
|
|
||||||
# Unified Anime Search endpoints
|
# Unified Anime Search endpoints
|
||||||
@app.get("/api/anime/search")
|
@app.get("/api/anime/search")
|
||||||
async def search_anime_unified(q: str, lang: str = "vostfr"):
|
async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: bool = False):
|
||||||
"""Search across all anime providers"""
|
"""
|
||||||
|
Search across all anime providers
|
||||||
|
|
||||||
|
Args:
|
||||||
|
q: Search query
|
||||||
|
lang: Language preference (vostfr, vf)
|
||||||
|
include_metadata: Whether to fetch full metadata (slower but more detailed)
|
||||||
|
"""
|
||||||
import time
|
import time
|
||||||
import asyncio
|
import asyncio
|
||||||
from app.providers import get_anime_providers
|
from app.providers import get_anime_providers
|
||||||
from app.downloaders import AnimeSamaDownloader, AnimeUltimeDownloader, NekoSamaDownloader, VostfreeDownloader
|
from app.downloaders import AnimeSamaDownloader, AnimeUltimeDownloader, NekoSamaDownloader, VostfreeDownloader
|
||||||
|
|
||||||
print(f"\n[SEARCH] Starting search for '{q}' in {lang}")
|
print(f"\n[SEARCH] Starting search for '{q}' in {lang} (metadata={include_metadata})")
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
@@ -184,7 +195,7 @@ async def search_anime_unified(q: str, lang: str = "vostfr"):
|
|||||||
if provider_id in downloaders:
|
if provider_id in downloaders:
|
||||||
downloader = downloaders[provider_id]
|
downloader = downloaders[provider_id]
|
||||||
print(f"[SEARCH] Queueing search on {provider_id}...")
|
print(f"[SEARCH] Queueing search on {provider_id}...")
|
||||||
search_tasks.append(downloader.search_anime(q, lang))
|
search_tasks.append(downloader.search_anime(q, lang, include_metadata=include_metadata))
|
||||||
provider_ids.append(provider_id)
|
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
|
||||||
@@ -207,10 +218,41 @@ async def search_anime_unified(q: str, lang: str = "vostfr"):
|
|||||||
return {
|
return {
|
||||||
"query": q,
|
"query": q,
|
||||||
"lang": lang,
|
"lang": lang,
|
||||||
|
"include_metadata": include_metadata,
|
||||||
"results": results
|
"results": results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/anime/metadata")
|
||||||
|
async def get_anime_metadata(url: str):
|
||||||
|
"""
|
||||||
|
Get detailed metadata for a specific anime
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The anime page URL
|
||||||
|
"""
|
||||||
|
from app.downloaders import get_downloader
|
||||||
|
|
||||||
|
try:
|
||||||
|
downloader = get_downloader(url)
|
||||||
|
|
||||||
|
# Check if the downloader has metadata support
|
||||||
|
if hasattr(downloader, 'get_anime_metadata'):
|
||||||
|
metadata = await downloader.get_anime_metadata(url)
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"metadata": metadata
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Downloader for {url} does not support metadata extraction"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/anime/episodes")
|
@app.get("/api/anime/episodes")
|
||||||
async def get_anime_episodes(url: str, lang: str = "vostfr"):
|
async def get_anime_episodes(url: str, lang: str = "vostfr"):
|
||||||
"""Get list of episodes for an anime"""
|
"""Get list of episodes for an anime"""
|
||||||
|
|||||||
+86
-8
@@ -377,6 +377,46 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.anime-metadata {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-synopsis {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(0, 217, 255, 0.05);
|
||||||
|
border-left: 3px solid #00d9ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-synopsis summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #00d9ff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-synopsis summary:hover {
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-synopsis p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ccc;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
@@ -537,6 +577,12 @@
|
|||||||
Rechercher
|
Rechercher
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 13px; color: #888;">
|
||||||
|
<input type="checkbox" id="includeMetadata" style="width: auto; margin: 0;">
|
||||||
|
<label for="includeMetadata" style="cursor: pointer; user-select: none;">
|
||||||
|
📊 Inclure les métadonnées (synopsis, genres, note) • Plus lent mais plus complet
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div style="margin-top: 10px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; font-size: 12px; color: #88;">
|
<div style="margin-top: 10px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; font-size: 12px; color: #88;">
|
||||||
💡 <strong>Astuce:</strong> Pour de meilleurs résultats, essayez le nom en anglais ou japonais (ex: "One Piece", "Naruto"). Certains sites n'ont pas tous les animes.
|
💡 <strong>Astuce:</strong> Pour de meilleurs résultats, essayez le nom en anglais ou japonais (ex: "One Piece", "Naruto"). Certains sites n'ont pas tous les animes.
|
||||||
</div>
|
</div>
|
||||||
@@ -594,6 +640,7 @@
|
|||||||
async function searchAnime() {
|
async function searchAnime() {
|
||||||
const query = document.getElementById('searchInput').value.trim();
|
const query = document.getElementById('searchInput').value.trim();
|
||||||
const lang = document.getElementById('langSelect').value;
|
const lang = document.getElementById('langSelect').value;
|
||||||
|
const includeMetadata = document.getElementById('includeMetadata').checked;
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
alert('Veuillez entrer un nom d\'anime');
|
alert('Veuillez entrer un nom d\'anime');
|
||||||
@@ -604,7 +651,7 @@
|
|||||||
resultsContainer.innerHTML = '<div class="loading-spinner">Recherche en cours...</div>';
|
resultsContainer.innerHTML = '<div class="loading-spinner">Recherche en cours...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/anime/search?q=${encodeURIComponent(query)}&lang=${lang}`);
|
const response = await fetch(`${API_BASE}/anime/search?q=${encodeURIComponent(query)}&lang=${lang}&include_metadata=${includeMetadata}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
displaySearchResults(data, lang);
|
displaySearchResults(data, lang);
|
||||||
@@ -627,14 +674,47 @@
|
|||||||
|
|
||||||
results.forEach(anime => {
|
results.forEach(anime => {
|
||||||
const providerInfo = providers.anime_providers[providerId];
|
const providerInfo = providers.anime_providers[providerId];
|
||||||
|
|
||||||
|
// Build metadata HTML if available
|
||||||
|
let metadataHtml = '';
|
||||||
|
if (anime.metadata) {
|
||||||
|
const meta = anime.metadata;
|
||||||
|
let metaParts = [];
|
||||||
|
|
||||||
|
if (meta.release_year) metaParts.push(`📅 ${meta.release_year}`);
|
||||||
|
if (meta.rating) metaParts.push(`⭐ ${meta.rating}`);
|
||||||
|
if (meta.genres && meta.genres.length > 0) metaParts.push(`🏷️ ${meta.genres.slice(0, 3).join(', ')}`);
|
||||||
|
if (meta.total_episodes) metaParts.push(`📺 ${meta.total_episodes} épisodes`);
|
||||||
|
if (meta.status) metaParts.push(`📡 ${meta.status === 'Ongoing' ? 'En cours' : 'Terminé'}`);
|
||||||
|
|
||||||
|
if (metaParts.length > 0) {
|
||||||
|
metadataHtml = `
|
||||||
|
<div class="anime-metadata">
|
||||||
|
${metaParts.join(' • ')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add synopsis if available (expandable)
|
||||||
|
if (meta.synopsis) {
|
||||||
|
metadataHtml += `
|
||||||
|
<details class="anime-synopsis">
|
||||||
|
<summary>📖 Synopsis</summary>
|
||||||
|
<p>${escapeHtml(meta.synopsis)}</p>
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="anime-card" id="anime-${providerId}-${encodeURIComponent(anime.url)}">
|
<div class="anime-card" id="anime-${providerId}-${encodeURIComponent(anime.url)}">
|
||||||
<div class="anime-card-header">
|
<div class="anime-card-header">
|
||||||
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
||||||
<div class="anime-card-provider">${providerInfo?.icon || ''} ${providerInfo?.name || providerId}</div>
|
<div class="anime-card-provider">${providerInfo?.icon || ''} ${providerInfo?.name || providerId}</div>
|
||||||
</div>
|
</div>
|
||||||
|
${metadataHtml}
|
||||||
<div class="anime-card-actions">
|
<div class="anime-card-actions">
|
||||||
<select id="episodes-${providerId}-${encodeURIComponent(anime.url)}" onchange="loadEpisodesForAnime('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}', this)">
|
<select id="episodes-${providerId}-${encodeURIComponent(anime.url)}">
|
||||||
<option value="">Charger les épisodes...</option>
|
<option value="">Charger les épisodes...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -739,14 +819,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Reset selection
|
// Show success message and refresh downloads
|
||||||
selectElement.value = '';
|
|
||||||
const actionsId = `actions-${providerId}-${encodedUrl}`;
|
|
||||||
document.getElementById(actionsId).style.display = 'none';
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
loadDownloads();
|
loadDownloads();
|
||||||
alert('Téléchargement démarré!');
|
alert('Téléchargement démarré!');
|
||||||
|
|
||||||
|
// Keep the select available for more downloads, just reset the selection
|
||||||
|
selectElement.value = '';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user