Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2e1bd8ab0 | |||
| 6168e9ed60 | |||
| 20cad0b4fe |
@@ -1,18 +1,57 @@
|
||||
# Ohm Stream Downloader
|
||||
|
||||
Web application pour télécharger des fichiers depuis divers hébergeurs (1fichier, Doodstream, Rapidfile, etc.).
|
||||
**Application web complète pour télécharger des animes et fichiers depuis divers hébergeurs.**
|
||||
|
||||
## Fonctionnalités
|
||||
Interface moderne avec recherche d'anime, métadonnées enrichies, téléchargements parallèles et streaming vidéo.
|
||||
|
||||
- **Multi-hébergeurs** : Support pour 1fichier, Doodstream, Rapidfile et plus
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
### 🎬 Recherche et Téléchargement d'Animes
|
||||
- **Recherche unifiée** : Recherchez sur 4 providers simultanément (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree)
|
||||
- **Métadonnées riches** : Synopsis, genres, notes, année de sortie, studio, nombre d'épisodes, statut
|
||||
- **Téléchargement par épisode** : Sélectionnez et téléchargez des épisodes individuels
|
||||
- **Téléchargement de saison complète** : Téléchargez tous les épisodes d'un coup
|
||||
- **Streaming vidéo** : Regardez vos animes directement dans le navigateur
|
||||
- **Recherche floue** : Gestion des fautes de frappe et variations de noms
|
||||
|
||||
### 📁 Hébergeurs de Fichiers Supportés
|
||||
- **1fichier** (1fichier.com, 1fichier.fr)
|
||||
- **Uptobox** (uptobox.com, uptobox.fr)
|
||||
- **Doodstream** (doodstream.com, dood.to, dood.lol, etc.)
|
||||
- **Rapidfile** (rapidfile.net, rapidfile.com)
|
||||
|
||||
### 🎥 Hébergeurs Vidéo Supportés
|
||||
- **VidMoly** (vidmoly.to, vidmoly.com)
|
||||
- **SendVid** (sendvid.com)
|
||||
|
||||
### 🚀 Gestion des Téléchargements
|
||||
- **Téléchargements parallèles** : Jusqu'à 3 téléchargements simultanés
|
||||
- **Pause/Reprise** : Mettez en pause et reprenez vos téléchargements
|
||||
- **Interface web moderne** : Interface intuitive avec progression en temps réel
|
||||
- **API REST** : Intégration facile avec d'autres applications
|
||||
- **Pause/Reprise** : Contrôle total sur vos téléchargements
|
||||
- **Progression en temps réel** : Vitesse, progression, taille
|
||||
- **Reprise automatique** : Support des HTTP Range pour reprendre les téléchargements interrompus
|
||||
|
||||
## Installation
|
||||
### 🌐 Interface Web
|
||||
- **Design moderne** : Interface sombre avec gradients et animations
|
||||
- **Responsive** : Fonctionne sur desktop et mobile
|
||||
- **Mise à jour automatique** : Rafraîchissement chaque seconde
|
||||
- **Métadonnées visuelles** : Affichage des informations anime avec icônes
|
||||
|
||||
### 🔌 API REST
|
||||
- **Endpoints REST** : Intégration facile avec d'autres applications
|
||||
- **Documentation automatique** : Swagger UI disponible
|
||||
|
||||
## 📋 Configuration Requise
|
||||
|
||||
- Python 3.8+
|
||||
- pip
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
```bash
|
||||
# Cloner le repository
|
||||
git clone https://github.com/votre-user/Ohm_streaming.git
|
||||
cd Ohm_streaming
|
||||
|
||||
# Créer l'environnement virtuel
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
@@ -20,48 +59,35 @@ source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
# Installer les dépendances
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Lancer le serveur
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
# Lancer le serveur de développement
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
||||
```
|
||||
|
||||
## Utilisation
|
||||
Accédez à l'interface : http://localhost:3000/web
|
||||
|
||||
## 📖 Utilisation
|
||||
|
||||
### Interface Web
|
||||
|
||||
Ouvrez votre navigateur sur : http://localhost:8000/web
|
||||
1. **Onglet Recherche d'Anime** :
|
||||
- Entrez le nom d'un anime (ex: "Naruto", "One Piece")
|
||||
- Sélectionnez la langue (VOSTFR ou VF)
|
||||
- Cochez "Inclure les métadonnées" pour plus d'informations
|
||||
- Cliquez sur "Rechercher"
|
||||
- Sélectionnez un épisode et cliquez sur "Télécharger"
|
||||
- Ou utilisez "Toute la saison" pour tout télécharger
|
||||
|
||||
Collez simplement un lien de téléchargement et cliquez sur "Télécharger".
|
||||
2. **Onglet Lien Direct** :
|
||||
- Collez un lien de téléchargement direct
|
||||
- Cliquez sur "Télécharger"
|
||||
|
||||
### API
|
||||
3. **Onglet Providers** :
|
||||
- Utilisez les onglets spécifiques à chaque provider
|
||||
- Chaque onglet a ses propres options de recherche
|
||||
|
||||
**Créer un téléchargement :**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/download \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://1fichier.com/?xxxxx"}'
|
||||
```
|
||||
### API Endpoints
|
||||
|
||||
**Lister les téléchargements :**
|
||||
```bash
|
||||
curl http://localhost:8000/api/downloads
|
||||
```
|
||||
|
||||
**Mettre en pause :**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/download/{task_id}/pause
|
||||
```
|
||||
|
||||
**Reprendre :**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/download/{task_id}/resume
|
||||
```
|
||||
|
||||
**Annuler :**
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8000/api/download/{task_id}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
#### Téléchargements
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
@@ -72,35 +98,236 @@ curl -X DELETE http://localhost:8000/api/download/{task_id}
|
||||
| POST | `/api/download/{task_id}/resume` | Reprendre |
|
||||
| DELETE | `/api/download/{task_id}` | Annuler/Supprimer |
|
||||
| GET | `/api/download/{task_id}/file` | Télécharger le fichier terminé |
|
||||
|
||||
#### Anime
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/api/anime/search` | Rechercher un anime (paramètres: `q`, `lang`, `include_metadata`) |
|
||||
| GET | `/api/anime/metadata` | Obtenir les métadonnées d'un anime (paramètre: `url`) |
|
||||
| GET | `/api/anime/episodes` | Liste des épisodes d'un anime (paramètres: `url`, `lang`) |
|
||||
| POST | `/api/anime/download` | Télécharger un épisode |
|
||||
| POST | `/api/anime/download-season` | Télécharger toute une saison |
|
||||
|
||||
#### Streaming Vidéo
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/video/{task_id}` | Stream une vidéo (support Range/seeking) |
|
||||
| GET | `/stream/{filename}` | Stream par nom de fichier |
|
||||
| GET | `/player/{task_id}` | Lecteur vidéo pour un téléchargement |
|
||||
| GET | `/watch/{filename}` | Lecteur vidéo par nom de fichier |
|
||||
|
||||
#### Système
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/` | Informations sur l'API |
|
||||
| GET | `/api/providers` | Liste des providers supportés |
|
||||
| GET | `/health` | Vérifier l'état du serveur |
|
||||
| GET | `/web` | Interface web |
|
||||
|
||||
## Structure du Projet
|
||||
### Exemples API
|
||||
|
||||
**Rechercher un anime avec métadonnées :**
|
||||
```bash
|
||||
curl "http://localhost:3000/api/anime/search?q=naruto&lang=vostfr&include_metadata=true"
|
||||
```
|
||||
|
||||
**Obtenir les épisodes d'un anime :**
|
||||
```bash
|
||||
curl "http://localhost:3000/api/anime/episodes?url=https://anime-sama.si/catalogue/naruto/saison1/vostfr/&lang=vostfr"
|
||||
```
|
||||
|
||||
**Télécharger une saison complète :**
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/anime/download-season?url=https://anime-sama.si/catalogue/naruto/saison1/vostfr/&lang=vostfr"
|
||||
```
|
||||
|
||||
**Créer un téléchargement direct :**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/download \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://1fichier.com/?xxxxx"}'
|
||||
```
|
||||
|
||||
## 🏗️ Structure du Projet
|
||||
|
||||
```
|
||||
Ohm_streaming/
|
||||
├── main.py # Application FastAPI
|
||||
├── main.py # Application FastAPI & endpoints API
|
||||
├── app/
|
||||
│ ├── models/ # Modèles de données
|
||||
│ ├── downloaders/ # Extracteurs de liens par hébergeur
|
||||
│ └── download_manager.py # Gestionnaire de téléchargements
|
||||
├── downloads/ # Fichiers téléchargés
|
||||
│ ├── models/ # Modèles Pydantic
|
||||
│ │ └── __init__.py # DownloadTask, AnimeMetadata, etc.
|
||||
│ ├── downloaders/ # Downloaders par provider
|
||||
│ │ ├── base.py # Classe BaseDownloader
|
||||
│ │ ├── animesama.py # Anime-Sama (avec métadonnées)
|
||||
│ │ ├── animeultime.py # Anime-Ultime (avec métadonnées)
|
||||
│ │ ├── nekosama.py # Neko-Sama (avec métadonnées)
|
||||
│ │ ├── vostfree.py # Vostfree (avec métadonnées)
|
||||
│ │ ├── unfichier.py # 1fichier
|
||||
│ │ ├── uptobox.py # Uptobox
|
||||
│ │ ├── doodstream.py # Doodstream
|
||||
│ │ ├── rapidfile.py # Rapidfile
|
||||
│ │ ├── vidmoly.py # VidMoly
|
||||
│ │ ├── sendvid.py # SendVid
|
||||
│ │ └── __init__.py # Registry des downloaders
|
||||
│ ├── providers.py # Configuration des providers
|
||||
│ └── download_manager.py # Gestionnaire de file d'attente
|
||||
├── downloads/ # Fichiers téléchargés
|
||||
├── templates/
|
||||
│ └── index.html # Interface web
|
||||
└── static/ # Fichiers statiques
|
||||
│ ├── index.html # Interface web principale
|
||||
│ └── player.html # Lecteur vidéo
|
||||
├── static/ # Fichiers statiques (CSS, JS, images)
|
||||
└── requirements.txt # Dépendances Python
|
||||
```
|
||||
|
||||
## Ajouter un Hébergeur
|
||||
## ⚙️ Configuration
|
||||
|
||||
Pour ajouter le support d'un nouvel hébergeur :
|
||||
Modifiez ces paramètres dans `main.py` :
|
||||
|
||||
1. Créez un fichier dans `app/downloaders/` (ex: `myhost.py`)
|
||||
2. Héritez de `BaseDownloader`
|
||||
3. Implémentez `can_handle(url)` et `get_download_link(url)`
|
||||
4. Ajoutez le downloader dans `app/downloaders/__init__.py`
|
||||
```python
|
||||
download_manager = DownloadManager(
|
||||
download_dir="downloads", # Répertoire de stockage
|
||||
max_parallel=3 # Téléchargements simultanés
|
||||
)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
## 🔧 Ajouter un Provider
|
||||
|
||||
- `max_parallel` : Nombre maximum de téléchargements simultanés (défaut: 3)
|
||||
- `download_dir` : Répertoire de stockage (défaut: "downloads")
|
||||
### Ajouter un Hébergeur de Fichiers
|
||||
|
||||
Modifiez ces paramètres dans `main.py`.
|
||||
1. Créez `app/downloaders/myhost.py` :
|
||||
```python
|
||||
from .base import BaseDownloader
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
class MyHostDownloader(BaseDownloader):
|
||||
def can_handle(self, url: str) -> bool:
|
||||
return "myhost.com" in url.lower()
|
||||
|
||||
async def get_download_link(self, url: str) -> tuple[str, str]:
|
||||
# Extraire le lien de téléchargement direct
|
||||
response = await self.client.get(url)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
# ... logique d'extraction ...
|
||||
return download_url, filename
|
||||
```
|
||||
|
||||
2. Ajoutez-le dans `app/providers.py` :
|
||||
```python
|
||||
FILE_HOSTS = {
|
||||
# ...
|
||||
"myhost": {
|
||||
"name": "MyHost",
|
||||
"domains": ["myhost.com"],
|
||||
"icon": "📁",
|
||||
"color": "#4ecdc4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ajouter un Provider Anime avec Métadonnées
|
||||
|
||||
1. Créez le downloader avec les méthodes requises :
|
||||
```python
|
||||
class MyAnimeDownloader(BaseDownloader):
|
||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False):
|
||||
# Implémenter la recherche
|
||||
|
||||
async def get_anime_metadata(self, anime_url: str) -> dict:
|
||||
# Extraire: synopsis, genres, rating, release_year, studio, etc.
|
||||
return {
|
||||
'synopsis': '...',
|
||||
'genres': ['Action', 'Aventure'],
|
||||
'rating': '8.5/10',
|
||||
'release_year': 2023,
|
||||
'studio': 'Studio Name',
|
||||
# ...
|
||||
}
|
||||
|
||||
async def get_episodes(self, anime_url: str, lang: str = "vostfr"):
|
||||
# Retourner la liste des épisodes
|
||||
```
|
||||
|
||||
2. Enregistrez-le dans `app/providers.py` et `main.py`
|
||||
|
||||
## 🗺️ Roadmap / Plans Futurs
|
||||
|
||||
### Version 2.2 - Améliorations des Métadonnées
|
||||
- [ ] **Affichage des posters** : Afficher les images de couverture dans les résultats de recherche
|
||||
- [ ] **Filtrage avancé** : Filtrer par genre, année, studio, statut
|
||||
- [ ] **Tri des résultats** : Par popularité, date, note
|
||||
- [ ] **Favoris** : Sauvegarder les animes favoris
|
||||
- [ ] **Historique** : Voir les animes récemment consultés
|
||||
|
||||
### Version 2.3 - Gestion de Bibliothèque
|
||||
- [ ] **Bibliothèque personnelle** : Gérer sa collection d'anime téléchargés
|
||||
- [ ] **Statistiques** : Temps de visionnage, espace disque utilisé
|
||||
- [ ] **Listes de lecture** : Créer des playlists personnalisées
|
||||
- [ ] **Marquage** : Marquer les épisodes comme vus/non vus
|
||||
- [ ] **Notes personnelles** : Noter les animes et laisser des commentaires
|
||||
|
||||
### Version 2.4 - Qualité et Formats
|
||||
- [ ] **Sélection de qualité** : Choisir entre 1080p, 720p, 480p
|
||||
- [ ] **Conversion automatique** : Convertir en différents formats
|
||||
- [ ] **Compression** : Réduire la taille des fichiers
|
||||
- [ ] **Extraction de sous-titres** : Télécharger les subs automatiquement
|
||||
- [ ] **Multi-audio** : Gérer les versions VF/VOSTFR
|
||||
|
||||
### Version 2.5 - Fonctionnalités Sociales
|
||||
- [ ] **Partage de listes** : Partager ses playlists avec amis
|
||||
- [ ] **Recommandations** : Suggestions basées sur l'historique
|
||||
- [ ] **Notes et avis** : Système de commentaires
|
||||
- [ ] **Intégration Discord/Telegram** : Notifications de nouveaux épisodes
|
||||
|
||||
### Version 2.6 - Mobile et Applications
|
||||
- [ ] **Application mobile** : App native iOS/Android
|
||||
- [ ] **PWA** : Progressive Web App pour offline
|
||||
- [ ] **Cast** : Chromecast/AirPlay support
|
||||
- [ ] **Download sur mobile** : Interface optimée mobile
|
||||
|
||||
### Version 3.0 - Fonctionnalités Avancées
|
||||
- [ ] **Sauvegarde cloud** : Sync avec Google Drive/Dropbox
|
||||
- [ ] **Streaming distant** : Regarder partout
|
||||
- [ ] **Multi-utilisateurs** : Profils et permissions
|
||||
- [ ] **API publique** : API pour développeurs tiers
|
||||
- [ ] **Plugins** : Système d'extensions
|
||||
|
||||
### Améliorations Continues
|
||||
- [ ] **Performance** : Optimisation du chargement et de l'interface
|
||||
- [ ] **Accessibilité** : Support lecteur d'écran, clavier
|
||||
- [ ] **Tests automatisés** : Suite de tests E2E
|
||||
- [ ] **Documentation** : Guides d'utilisation et API
|
||||
- [ ] **Internationalisation** : Support multilingue complet
|
||||
|
||||
## 🤝 Contribution
|
||||
|
||||
Les contributions sont les bienvenues !
|
||||
|
||||
1. Fork le projet
|
||||
2. Créez une branche (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push (`git push origin feature/AmazingFeature`)
|
||||
5. Ouvrez une Pull Request
|
||||
|
||||
## 📝 Licence
|
||||
|
||||
Ce projet est à usage éducatif uniquement. Respectez les droits d'auteur et les lois locales.
|
||||
|
||||
## ⚠️ Avertissement
|
||||
|
||||
Ce logiciel est destiné à un usage personnel et éducatif. Les utilisateurs sont responsables de vérifier qu'ils ont le droit de télécharger du contenu protégé par des droits d'auteur dans leur juridiction.
|
||||
|
||||
## 📧 Support
|
||||
|
||||
Pour les bugs et suggestions :
|
||||
- Ouvrez une issue sur GitHub
|
||||
- Discutez avec la communauté
|
||||
|
||||
---
|
||||
|
||||
**Développé avec ❤️ pour la communauté anime**
|
||||
|
||||
*Version actuelle : 2.1*
|
||||
*Dernière mise à jour : Janvier 2026*
|
||||
|
||||
@@ -346,11 +346,188 @@ class AnimeSamaDownloader(BaseDownloader):
|
||||
filename = f"{anime_name} - Episode {episode}.mp4"
|
||||
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
|
||||
Returns list of anime with title, url, and cover image
|
||||
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:
|
||||
# Update domains before searching to ensure we have the current domain
|
||||
@@ -395,12 +572,20 @@ class AnimeSamaDownloader(BaseDownloader):
|
||||
if '/saison1/' not in href:
|
||||
href = href.rstrip('/') + f'/saison1/{lang}/'
|
||||
|
||||
results.append({
|
||||
result = {
|
||||
'title': title,
|
||||
'url': href,
|
||||
'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")
|
||||
return results
|
||||
|
||||
@@ -165,10 +165,124 @@ class AnimeUltimeDownloader(BaseDownloader):
|
||||
filename = f"{anime_name} - Episode {episode}.mp4"
|
||||
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
|
||||
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:
|
||||
import time
|
||||
@@ -231,11 +345,19 @@ class AnimeUltimeDownloader(BaseDownloader):
|
||||
if not href.startswith('http'):
|
||||
href = urljoin("https://www.anime-ultime.net/", href)
|
||||
|
||||
results.append({
|
||||
result_item = {
|
||||
'title': better_title,
|
||||
'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")
|
||||
return results
|
||||
|
||||
+109
-4
@@ -111,9 +111,107 @@ class NekoSamaDownloader(BaseDownloader):
|
||||
except Exception as e:
|
||||
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
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
lang: Language preference (vostfr, vf)
|
||||
include_metadata: Whether to fetch full metadata for each result (slower)
|
||||
"""
|
||||
try:
|
||||
import time
|
||||
@@ -130,11 +228,18 @@ class NekoSamaDownloader(BaseDownloader):
|
||||
|
||||
if response.status_code == 200:
|
||||
print(f"[NEKO-SAMA] Found anime at {str(response.url)}")
|
||||
return [{
|
||||
result = {
|
||||
'title': query,
|
||||
'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")
|
||||
return []
|
||||
|
||||
+113
-4
@@ -111,9 +111,111 @@ class VostfreeDownloader(BaseDownloader):
|
||||
except Exception as e:
|
||||
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
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
lang: Language preference (vostfr, vf)
|
||||
include_metadata: Whether to fetch full metadata for each result (slower)
|
||||
"""
|
||||
try:
|
||||
import time
|
||||
@@ -130,11 +232,18 @@ class VostfreeDownloader(BaseDownloader):
|
||||
|
||||
if response.status_code == 200:
|
||||
print(f"[VOSTFREE] Found anime at {str(response.url)}")
|
||||
return [{
|
||||
result = {
|
||||
'title': query,
|
||||
'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")
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Favorites management system for Ohm Stream Downloader
|
||||
Stores user's favorite anime with metadata in a local JSON file
|
||||
"""
|
||||
import json
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
import aiofiles
|
||||
|
||||
|
||||
class FavoritesManager:
|
||||
"""Manages user's favorite anime list"""
|
||||
|
||||
def __init__(self, storage_path: str = "data/favorites.json"):
|
||||
self.storage_path = Path(storage_path)
|
||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._favorites: Dict[str, Dict] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _load(self):
|
||||
"""Load favorites from disk"""
|
||||
async with self._lock:
|
||||
if self.storage_path.exists():
|
||||
try:
|
||||
async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
self._favorites = json.loads(content) if content.strip() else {}
|
||||
except Exception as e:
|
||||
print(f"Error loading favorites: {e}")
|
||||
self._favorites = {}
|
||||
else:
|
||||
self._favorites = {}
|
||||
|
||||
async def _save(self):
|
||||
"""Save favorites to disk"""
|
||||
async with self._lock:
|
||||
try:
|
||||
async with aiofiles.open(self.storage_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(json.dumps(self._favorites, indent=2, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
print(f"Error saving favorites: {e}")
|
||||
|
||||
async def add_favorite(
|
||||
self,
|
||||
anime_id: str,
|
||||
title: str,
|
||||
url: str,
|
||||
provider: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
poster_url: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Add an anime to favorites"""
|
||||
await self._load()
|
||||
|
||||
if anime_id in self._favorites:
|
||||
# Update existing favorite
|
||||
self._favorites[anime_id]["updated_at"] = datetime.now().isoformat()
|
||||
if metadata:
|
||||
self._favorites[anime_id]["metadata"] = metadata
|
||||
if poster_url:
|
||||
self._favorites[anime_id]["poster_url"] = poster_url
|
||||
else:
|
||||
# Add new favorite
|
||||
self._favorites[anime_id] = {
|
||||
"id": anime_id,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"provider": provider,
|
||||
"metadata": metadata or {},
|
||||
"poster_url": poster_url,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
await self._save()
|
||||
return self._favorites[anime_id]
|
||||
|
||||
async def remove_favorite(self, anime_id: str) -> bool:
|
||||
"""Remove an anime from favorites"""
|
||||
await self._load()
|
||||
|
||||
if anime_id in self._favorites:
|
||||
del self._favorites[anime_id]
|
||||
await self._save()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def get_favorite(self, anime_id: str) -> Optional[Dict]:
|
||||
"""Get a specific favorite by ID"""
|
||||
await self._load()
|
||||
return self._favorites.get(anime_id)
|
||||
|
||||
async def list_favorites(
|
||||
self,
|
||||
sort_by: str = "created_at",
|
||||
order: str = "desc",
|
||||
filter_provider: Optional[str] = None,
|
||||
filter_genre: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""List all favorites with optional sorting and filtering"""
|
||||
await self._load()
|
||||
|
||||
favorites = list(self._favorites.values())
|
||||
|
||||
# Apply filters
|
||||
if filter_provider:
|
||||
favorites = [f for f in favorites if f["provider"] == filter_provider]
|
||||
|
||||
if filter_genre:
|
||||
favorites = [
|
||||
f for f in favorites
|
||||
if filter_genre in f.get("metadata", {}).get("genres", [])
|
||||
]
|
||||
|
||||
# Sort favorites
|
||||
reverse = order == "desc"
|
||||
if sort_by == "title":
|
||||
favorites.sort(key=lambda x: x["title"].lower(), reverse=reverse)
|
||||
elif sort_by == "rating":
|
||||
favorites.sort(
|
||||
key=lambda x: float(x.get("metadata", {}).get("rating", "0").split("/")[0]),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_by == "year":
|
||||
favorites.sort(
|
||||
key=lambda x: x.get("metadata", {}).get("release_year", 0),
|
||||
reverse=reverse
|
||||
)
|
||||
else: # created_at, updated_at
|
||||
favorites.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse)
|
||||
|
||||
return favorites
|
||||
|
||||
async def is_favorite(self, anime_id: str) -> bool:
|
||||
"""Check if an anime is in favorites"""
|
||||
await self._load()
|
||||
return anime_id in self._favorites
|
||||
|
||||
async def toggle_favorite(
|
||||
self,
|
||||
anime_id: str,
|
||||
title: str,
|
||||
url: str,
|
||||
provider: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
poster_url: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Toggle an anime in favorites (add if not exists, remove if exists)"""
|
||||
is_fav = await self.is_favorite(anime_id)
|
||||
|
||||
if is_fav:
|
||||
await self.remove_favorite(anime_id)
|
||||
return {"action": "removed", "anime_id": anime_id}
|
||||
else:
|
||||
fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url)
|
||||
return {"action": "added", "anime_id": anime_id, "favorite": fav}
|
||||
|
||||
async def get_stats(self) -> Dict:
|
||||
"""Get statistics about favorites"""
|
||||
await self._load()
|
||||
|
||||
total = len(self._favorites)
|
||||
|
||||
# Count by provider
|
||||
by_provider = {}
|
||||
for fav in self._favorites.values():
|
||||
provider = fav["provider"]
|
||||
by_provider[provider] = by_provider.get(provider, 0) + 1
|
||||
|
||||
# Count by genre
|
||||
by_genre = {}
|
||||
for fav in self._favorites.values():
|
||||
for genre in fav.get("metadata", {}).get("genres", []):
|
||||
by_genre[genre] = by_genre.get(genre, 0) + 1
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_provider": by_provider,
|
||||
"by_genre": by_genre
|
||||
}
|
||||
|
||||
|
||||
# Global favorites manager instance
|
||||
_favorites_manager: Optional[FavoritesManager] = None
|
||||
|
||||
|
||||
def get_favorites_manager() -> FavoritesManager:
|
||||
"""Get the global favorites manager instance"""
|
||||
global _favorites_manager
|
||||
if _favorites_manager is None:
|
||||
_favorites_manager = FavoritesManager()
|
||||
return _favorites_manager
|
||||
@@ -40,3 +40,26 @@ class DownloadTask(BaseModel):
|
||||
class DownloadRequest(BaseModel):
|
||||
url: str
|
||||
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
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models import DownloadRequest, DownloadTask, DownloadStatus
|
||||
from app.download_manager import DownloadManager
|
||||
from app.downloaders import AnimeSamaDownloader
|
||||
from app import providers
|
||||
from app.favorites import get_favorites_manager
|
||||
|
||||
app = FastAPI(title="Ohm Stream Downloader")
|
||||
|
||||
@@ -42,7 +43,7 @@ async def root():
|
||||
return {
|
||||
"message": "Ohm Stream Downloader API",
|
||||
"status": "running",
|
||||
"version": "2.0",
|
||||
"version": "2.2",
|
||||
"endpoints": {
|
||||
"POST /api/download": "Start a new download",
|
||||
"GET /api/downloads": "List all downloads",
|
||||
@@ -51,6 +52,16 @@ async def root():
|
||||
"POST /api/download/{task_id}/resume": "Resume a download",
|
||||
"DELETE /api/download/{task_id}": "Cancel a download",
|
||||
"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 /api/favorites": "List all favorite anime",
|
||||
"POST /api/favorites": "Add anime to favorites",
|
||||
"DELETE /api/favorites/{anime_id}": "Remove from favorites",
|
||||
"GET /api/favorites/{anime_id}": "Get favorite anime details",
|
||||
"GET /api/favorites/stats": "Get favorites statistics",
|
||||
"POST /api/favorites/toggle": "Toggle anime in favorites",
|
||||
"GET /web": "Web interface"
|
||||
}
|
||||
}
|
||||
@@ -156,14 +167,21 @@ async def download_file(task_id: str):
|
||||
|
||||
# Unified Anime Search endpoints
|
||||
@app.get("/api/anime/search")
|
||||
async def search_anime_unified(q: str, lang: str = "vostfr"):
|
||||
"""Search across all anime providers"""
|
||||
async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: bool = False):
|
||||
"""
|
||||
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 asyncio
|
||||
from app.providers import get_anime_providers
|
||||
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()
|
||||
|
||||
results = {}
|
||||
@@ -184,7 +202,7 @@ async def search_anime_unified(q: str, lang: str = "vostfr"):
|
||||
if provider_id in downloaders:
|
||||
downloader = downloaders[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)
|
||||
|
||||
# Wait for all searches to complete with a timeout per provider
|
||||
@@ -207,10 +225,41 @@ async def search_anime_unified(q: str, lang: str = "vostfr"):
|
||||
return {
|
||||
"query": q,
|
||||
"lang": lang,
|
||||
"include_metadata": include_metadata,
|
||||
"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")
|
||||
async def get_anime_episodes(url: str, lang: str = "vostfr"):
|
||||
"""Get list of episodes for an anime"""
|
||||
@@ -505,6 +554,143 @@ async def video_player_by_filename(request: Request, filename: str):
|
||||
})
|
||||
|
||||
|
||||
# ==================== FAVORITES API ====================
|
||||
|
||||
@app.get("/api/favorites")
|
||||
async def list_favorites(
|
||||
sort_by: str = "created_at",
|
||||
order: str = "desc",
|
||||
filter_provider: str = None,
|
||||
filter_genre: str = None
|
||||
):
|
||||
"""
|
||||
List all favorite anime with optional sorting and filtering
|
||||
|
||||
Query params:
|
||||
- sort_by: title, rating, year, created_at, updated_at (default: created_at)
|
||||
- order: asc, desc (default: desc)
|
||||
- filter_provider: Filter by provider (anime-sama, neko-sama, etc.)
|
||||
- filter_genre: Filter by genre (Action, Adventure, etc.)
|
||||
"""
|
||||
fav_manager = get_favorites_manager()
|
||||
favorites = await fav_manager.list_favorites(
|
||||
sort_by=sort_by,
|
||||
order=order,
|
||||
filter_provider=filter_provider,
|
||||
filter_genre=filter_genre
|
||||
)
|
||||
return {
|
||||
"favorites": favorites,
|
||||
"total": len(favorites),
|
||||
"filters": {
|
||||
"sort_by": sort_by,
|
||||
"order": order,
|
||||
"provider": filter_provider,
|
||||
"genre": filter_genre
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/favorites")
|
||||
async def add_favorite(request: Request):
|
||||
"""
|
||||
Add an anime to favorites
|
||||
|
||||
Body params (JSON):
|
||||
- anime_id: Unique identifier (e.g., provider + slug)
|
||||
- title: Anime title
|
||||
- url: Anime page URL
|
||||
- provider: Provider name
|
||||
- metadata: Optional metadata dict (synopsis, genres, rating, etc.)
|
||||
- poster_url: Optional poster image URL
|
||||
"""
|
||||
import json
|
||||
data = await request.json()
|
||||
|
||||
required_fields = ["anime_id", "title", "url", "provider"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
favorite = await fav_manager.add_favorite(
|
||||
anime_id=data["anime_id"],
|
||||
title=data["title"],
|
||||
url=data["url"],
|
||||
provider=data["provider"],
|
||||
metadata=data.get("metadata"),
|
||||
poster_url=data.get("poster_url")
|
||||
)
|
||||
|
||||
return {"status": "added", "favorite": favorite}
|
||||
|
||||
|
||||
@app.delete("/api/favorites/{anime_id}")
|
||||
async def remove_favorite(anime_id: str):
|
||||
"""Remove an anime from favorites"""
|
||||
fav_manager = get_favorites_manager()
|
||||
removed = await fav_manager.remove_favorite(anime_id)
|
||||
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
||||
|
||||
return {"status": "removed", "anime_id": anime_id}
|
||||
|
||||
|
||||
@app.get("/api/favorites/{anime_id}")
|
||||
async def get_favorite(anime_id: str):
|
||||
"""Get details of a specific favorite anime"""
|
||||
fav_manager = get_favorites_manager()
|
||||
favorite = await fav_manager.get_favorite(anime_id)
|
||||
|
||||
if not favorite:
|
||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
||||
|
||||
return {"favorite": favorite}
|
||||
|
||||
|
||||
@app.get("/api/favorites/stats")
|
||||
async def get_favorites_stats():
|
||||
"""Get statistics about favorites"""
|
||||
fav_manager = get_favorites_manager()
|
||||
stats = await fav_manager.get_stats()
|
||||
return stats
|
||||
|
||||
|
||||
@app.post("/api/favorites/toggle")
|
||||
async def toggle_favorite(request: Request):
|
||||
"""
|
||||
Toggle an anime in favorites (add if not exists, remove if exists)
|
||||
|
||||
Body params (JSON):
|
||||
- anime_id: Unique identifier
|
||||
- title: Anime title
|
||||
- url: Anime page URL
|
||||
- provider: Provider name
|
||||
- metadata: Optional metadata dict
|
||||
- poster_url: Optional poster image URL
|
||||
"""
|
||||
import json
|
||||
data = await request.json()
|
||||
|
||||
required_fields = ["anime_id", "title", "url", "provider"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
result = await fav_manager.toggle_favorite(
|
||||
anime_id=data["anime_id"],
|
||||
title=data["title"],
|
||||
url=data["url"],
|
||||
provider=data["provider"],
|
||||
metadata=data.get("metadata"),
|
||||
poster_url=data.get("poster_url")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
|
||||
+86
-8
@@ -377,6 +377,46 @@
|
||||
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 {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
@@ -537,6 +577,12 @@
|
||||
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" 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;">
|
||||
💡 <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>
|
||||
@@ -594,6 +640,7 @@
|
||||
async function searchAnime() {
|
||||
const query = document.getElementById('searchInput').value.trim();
|
||||
const lang = document.getElementById('langSelect').value;
|
||||
const includeMetadata = document.getElementById('includeMetadata').checked;
|
||||
|
||||
if (!query) {
|
||||
alert('Veuillez entrer un nom d\'anime');
|
||||
@@ -604,7 +651,7 @@
|
||||
resultsContainer.innerHTML = '<div class="loading-spinner">Recherche en cours...</div>';
|
||||
|
||||
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();
|
||||
|
||||
displaySearchResults(data, lang);
|
||||
@@ -627,14 +674,47 @@
|
||||
|
||||
results.forEach(anime => {
|
||||
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 += `
|
||||
<div class="anime-card" id="anime-${providerId}-${encodeURIComponent(anime.url)}">
|
||||
<div class="anime-card-header">
|
||||
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
||||
<div class="anime-card-provider">${providerInfo?.icon || ''} ${providerInfo?.name || providerId}</div>
|
||||
</div>
|
||||
${metadataHtml}
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
@@ -739,14 +819,12 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Reset selection
|
||||
selectElement.value = '';
|
||||
const actionsId = `actions-${providerId}-${encodedUrl}`;
|
||||
document.getElementById(actionsId).style.display = 'none';
|
||||
|
||||
// Show success message
|
||||
// Show success message and refresh downloads
|
||||
loadDownloads();
|
||||
alert('Téléchargement démarré!');
|
||||
|
||||
// Keep the select available for more downloads, just reset the selection
|
||||
selectElement.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
|
||||
Reference in New Issue
Block a user