diff --git a/.gitignore b/.gitignore index f942a44..abb8640 100644 --- a/.gitignore +++ b/.gitignore @@ -47,10 +47,24 @@ favorites.json ohm_streaming.db # Config (runtime-generated) -config/anime_sama_domain.json -config/metadata_cache.json +config/*.json +!config/*.example.json data/ favorites.json *.db *.sqlite ohm_streaming.db + +# Node +node_modules/ +package-lock.json.tmp +playwright-report/ +test-results/ + +# Agent/Tool specific +.serena/ +.sisyphus/ +.claude/ +.opencode/ +.mypy_cache/ +.ruff_cache/ diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index 0ef0d30..3694c36 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -1,9 +1,9 @@ { - "active_plan": "/opt/Ohm_streaming/.sisyphus/plans/watchlist-visual-redesign.md", - "started_at": "2026-02-26T14:52:06.065Z", + "active_plan": "/opt/Ohm_streaming/.sisyphus/plans/cors-fix.md", + "started_at": "2026-03-18T13:17:43.401Z", "session_ids": [ - "ses_36604025effe0D8w29Z4LdkaPr" + "ses_3388359e2ffe5brQanNc9Qb8FL" ], - "plan_name": "watchlist-visual-redesign", + "plan_name": "cors-fix", "agent": "atlas" } \ No newline at end of file diff --git a/README.md b/README.md index eebe0df..9c791b3 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,21 @@ **Application web complète pour rechercher, streamer et télécharger des animes, séries TV et films.** -Interface moderne (SPA-like) avec recherche unifiée, watchlist automatique, métadonnées enrichies, téléchargements parallèles et intégration Sonarr. +Interface moderne (SPA-like) avec recherche unifiée, watchlist automatique, métadonnées enrichies, téléchargements parallèles et intégration Sonarr. Propulsée par FastAPI, SQLModel et une interface dynamique HTMX/Alpine.js. ## ✨ Fonctionnalités ### 🎬 Recherche & Streaming -- **Recherche unifiée** : Recherchez animes et séries TV simultanément. +- **Recherche unifiée** : Recherchez animes et séries TV simultanément via plusieurs sources. - **Providers Anime** : Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga. - **Providers Séries** : FS7 (French-Stream). - **Métadonnées riches** : Synopsis, genres, notes, studio via intégration Kitsu. -- **Streaming vidéo** : Lecteur intégré supportant divers hébergeurs. +- **Streaming vidéo** : Lecteur **Plyr.io** intégré supportant plus de 15 hébergeurs. - **Téléchargement flexible** : Épisode par épisode ou saison complète. ### 📋 Watchlist & Automatisation -- **Suivi intelligent** : Ajoutez des animes à votre watchlist pour ne rater aucun épisode. -- **Auto-Download** : Téléchargement automatique des nouveaux épisodes dès leur sortie. +- **Suivi intelligent** : Ajoutez des titres à votre watchlist pour ne rater aucun épisode. +- **Auto-Download** : Téléchargement automatique des nouveaux épisodes dès leur parution. - **Planificateur (Scheduler)** : Vérification périodique configurable (1h à 168h). - **Filtres avancés** : Visualisation par statut (Actif, En pause, Terminé). - **Intégration Sonarr** : Support des webhooks pour une automatisation complète du homelab. @@ -27,42 +27,58 @@ Interface moderne (SPA-like) avec recherche unifiée, watchlist automatique, mé - **Progression Temps Réel** : Vitesse (Mo/s), pourcentage et estimation du temps restant. - **Sanitisation** : Nettoyage automatique des noms de fichiers pour une compatibilité maximale. -## 🏗️ Architecture (Three-Tier System) +## 🏗️ Architecture & Stack Technique -L'application repose sur un système à trois couches pour une robustesse maximale : -1. **Catalogues (Anime/Series Sites)** : Extraction des listes d'épisodes et métadonnées. -2. **Players Vidéo (Video Players)** : Extraction des liens de téléchargement direct depuis les embeds (VidMoly, DoodStream, etc.). -3. **Manager (Download Manager)** : Orchestration asynchrone des transferts de fichiers. +L'application repose sur une architecture moderne et robuste : +- **Backend** : Python 3.11+, **FastAPI** pour l'API asynchrone. +- **Base de Données** : **SQLModel** (SQLAlchemy + Pydantic) avec **SQLite**. +- **Migrations** : **Alembic** pour la gestion évolutive du schéma de données. +- **Frontend** : **HTMX** pour les interactions serveur, **Alpine.js** pour l'état client, **Vanilla CSS**. +- **Streaming** : **Plyr.io** pour une expérience de lecture fluide et moderne. ## 📁 Hébergeurs Supportés | Type | Services Supportés | | :--- | :--- | | **Catalogues** | Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga, FS7 | -| **Players/Hosts** | VidMoly, DoodStream, 1fichier, Uptobox, SendVid, Sibnet, Lplayer, Uqload, Rapidfile, LuLuvid, Smoothpre, Vidzy | +| **Players/Hosts** | VidMoly, DoodStream, 1fichier, Uptobox, SendVid, Sibnet, Lplayer, Uqload, Rapidfile, LuLuvid, Smoothpre, Vidzy, **OneUpload** | -## 📋 Configuration Requise +## 🚀 Installation & Configuration -- **Python 3.11+** -- **Node.js** (pour les tests frontend uniquement) -- **Playwright** (pour l'extraction dynamique sur certains sites) - -## 🚀 Installation Rapide +### 1. Prérequis +- Python 3.11+ +- Node.js (pour les tests optionnels) +- Playwright (requis pour l'extraction de certains lecteurs comme VidMoly) +### 2. Installation ```bash # Cloner le repository git clone https://git.lanro.eu/Roman/ohm_streaming.git cd ohm_streaming -# Environnement Python +# Créer et activer l'environnement virtuel python3 -m venv venv source venv/bin/activate + +# Installer les dépendances pip install -r requirements.txt -# Initialisation Playwright (requis pour VidMoly) +# Initialisation Playwright playwright install chromium +``` -# Lancer l'application +### 3. Configuration +Créez un fichier `.env` à la racine du projet (voir `.env.example`). +**Note importante sur la sécurité :** Générez une clé secrète JWT sécurisée. + +```bash +# Commande pour générer une clé secrète +python3 -c "import secrets; print(secrets.token_urlsafe(32))" +``` + +### 4. Lancement +```bash +# Lancer l'application (Port 3000 par défaut) uvicorn main:app --reload --host 0.0.0.0 --port 3000 ``` Accès Web : `http://localhost:3000/web` @@ -76,52 +92,34 @@ pytest -m "unit" # Tests unitaires rapides # Frontend (Vitest & Playwright) npm test # Tests unitaires JS -npx playwright test # Tests E2E +npx playwright test # Tests E2E complets ``` ## 🏗️ Structure du Projet ``` Ohm_streaming/ -├── main.py # Point d'entrée & API FastAPI +├── main.py # Point d'entrée & Middleware FastAPI ├── app/ -│ ├── downloaders/ # Logique d'extraction (Scraping) -│ │ ├── anime_sites/ # Catalogues Anime -│ │ ├── series_sites/ # Catalogues Séries -│ │ └── video_players/ # Extracteurs de liens directs -│ ├── routers/ # Routes API modulaires (Auth, Watchlist, etc.) +│ ├── downloaders/ # Logique d'extraction (Scraping 3-tier) +│ ├── models/ # Modèles SQLModel & Pydantic +│ ├── routers/ # Routes API modulaires │ ├── download_manager.py # Moteur de téléchargement asynchrone │ ├── watchlist.py # Logique métier du suivi -│ └── scheduler.py # Planificateur de tâches -├── static/ # Frontend (JS Vanilla, CSS) -├── templates/ # Vues Jinja2 -└── config/ # Données persistantes (JSON) +│ └── database.py # Configuration de la base de données +├── alembic/ # Migrations de base de données +├── static/ # Frontend (JS, CSS, Img) +├── templates/ # Vues Jinja2 (avec HTMX & Alpine.js) +└── downloads/ # Répertoire par défaut des médias ``` -## 🗺️ Plan d'Évolution Global (Modernisation) - -### ✅ Phase 1 : Restructuration (Terminé) -- Migration vers une architecture modulaire pour les downloaders. -- Séparation stricte entre catalogues et hébergeurs vidéo. -- Amélioration de la gestion des erreurs et des retries. - -### ✅ Phase 2 : Consolidation & SQL (Terminé) -- Migration complète des fichiers JSON vers **SQLModel** (SQLite). -- Mise en place d'**Alembic** pour les migrations de base de données. -- Centralisation des métadonnées et persistance robuste. - -### 🏗️ Phase 3 : UX & Modernisation Frontend (En cours) -- Adoption de **HTMX/Alpine.js** pour dynamiser l'interface. -- Intégration du lecteur vidéo avancé **Plyr.io**. -- Amélioration de la réactivité de la recherche et de la watchlist. - ## 📝 Licence & Sécurité - Ce projet est à usage **éducatif et personnel** uniquement. - Respectez les droits d'auteur et les conditions d'utilisation des sites sources. -- Ne partagez jamais votre `JWT_SECRET_KEY` en production. +- L'utilisation de ce logiciel est sous votre entière responsabilité. --- -**Version actuelle : 2.3** +**Version actuelle : 2.4** **Dernière mise à jour : Mars 2026** **Développé avec ❤️ pour la communauté anime** diff --git a/app/auto_download_scheduler.py b/app/auto_download_scheduler.py index 556d784..19826b7 100644 --- a/app/auto_download_scheduler.py +++ b/app/auto_download_scheduler.py @@ -66,7 +66,7 @@ class AutoDownloadScheduler: self.scheduler = AsyncIOScheduler() # Get initial check interval from settings - settings = self.wlm.get_settings() + settings = self.wlm.settings interval_hours = settings.check_interval_hours # Add the job for episode checking diff --git a/app/downloaders/series_sites/fs7.py b/app/downloaders/series_sites/fs7.py index afb2f60..7f7dfe1 100644 --- a/app/downloaders/series_sites/fs7.py +++ b/app/downloaders/series_sites/fs7.py @@ -68,38 +68,66 @@ class FS7Downloader(BaseSeriesSite): soup = BeautifulSoup(html, 'lxml') results = [] - # Look for series items (FS7 has both films and series in search results) - # We filter for /s-tv/ URLs ending with .html (actual series/season pages) - items = soup.find_all('a', href=re.compile(r'/s-tv/\d+-.+\.html')) + # Look for series items + # FS7 usually structure:
+ # Or directly tags with images + items = soup.find_all('div', class_='movie-item') + if not items: + # Fallback to the previous method if layout is different + items = soup.find_all('a', href=re.compile(r'/s-tv/\d+-.+\.html')) - for item in items[:20]: # Limit to 20 results - url = item.get('href', '') + for item in items[:24]: # Limit to 24 results + # Find the link and image within the item or the item itself + if item.name == 'a': + link_elem = item + else: + link_elem = item.find('a', href=re.compile(r'/s-tv/|/films/')) + + if not link_elem: + continue + + url = link_elem.get('href', '') if not url.startswith('http'): url = urljoin(self.base_url, url) - # Extract title from the item - title_elem = item.find('img', alt=True) - if title_elem: - title = title_elem.get('alt', '').strip() + # Extract title + img_elem = item.find('img') + title = "" + if img_elem and img_elem.get('alt'): + title = img_elem.get('alt').strip() + elif link_elem.get('title'): + title = link_elem.get('title').strip() else: - # Get text content and clean it - text = item.get_text(strip=True) - # Skip if it's just a category name - if any(cat in text.lower() for cat in ['séries', 'series', 'vf', 'vostfr', 'vo', 'netflix', 'disney', 'amazon', 'apple']): - continue - title = text - - # Clean up title: remove "affiche" suffix and clean extra whitespace - title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip() - title = re.sub(r'\s+', ' ', title) # Normalize whitespace + title = item.get_text(strip=True) # Extract cover image - img = item.find('img') - cover_image = img.get('src', '') if img else '' + img_elem = item.find('img') + cover_image = "" + if img_elem: + # Check for common lazy loading attributes used by various themes + cover_image = ( + img_elem.get('data-src') or + img_elem.get('data-original') or + img_elem.get('src') or + "" + ) + + # If still empty, look for background-style images in inline styles + if not cover_image: + style = item.get('style', '') + if 'background-image' in style: + match = re.search(r'url\([\'"]?(.*?)[\'"]?\)', style) + if match: + cover_image = match.group(1) - # Only add if we have a title and it's not empty - if title and len(title) > 5: - # Avoid duplicates + if cover_image and not cover_image.startswith('http'): + cover_image = urljoin(self.base_url, cover_image) + + # Clean up title + title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip() + title = re.sub(r'\s+', ' ', title) + + if title and len(title) > 2: if not any(r['url'] == url for r in results): results.append({ 'title': title, diff --git a/app/recommendation_engine.py b/app/recommendation_engine.py index 8fc226e..76e918f 100644 --- a/app/recommendation_engine.py +++ b/app/recommendation_engine.py @@ -214,6 +214,7 @@ class RecommendationEngine: if not any(anime_lower == dl.lower() for dl in downloaded_anime): recommendations.append({ **anime, + 'cover_image': anime.get('cover_image'), 'recommendation_reason': f"Similaire à {anime_name}", 'relevance_score': 0.9 }) @@ -237,6 +238,7 @@ class RecommendationEngine: recommendations.append({ **anime, + 'cover_image': anime.get('cover_image'), 'recommendation_reason': 'Nouveau de la saison' + (' (vos genres!)' if genre_match else ''), 'relevance_score': 0.8 if genre_match else 0.6 }) diff --git a/app/recommendations.py b/app/recommendations.py index 3eadc94..98c134e 100644 --- a/app/recommendations.py +++ b/app/recommendations.py @@ -22,13 +22,11 @@ class AnimeReleasesFetcher: async def _rate_limited_request(self, url: str) -> httpx.Response: """Make a rate-limited request to Jikan API""" - # Enforce minimum delay between requests if self._last_request_time: elapsed = (datetime.now() - self._last_request_time).total_seconds() if elapsed < self._min_request_interval: await asyncio.sleep(self._min_request_interval - elapsed) - # Retry logic with exponential backoff max_retries = 3 base_delay = 1.0 @@ -37,7 +35,6 @@ class AnimeReleasesFetcher: response = await self.client.get(url) self._last_request_time = datetime.now() - # Handle rate limiting (HTTP 429) if response.status_code == 429: if attempt < max_retries - 1: delay = base_delay * (2 ** attempt) @@ -58,31 +55,35 @@ class AnimeReleasesFetcher: else: raise Exception(f"Request timeout after {max_retries} retries") from e except Exception as e: - # For any other exception, don't retry raise + def _extract_cover_image(self, anime_data: Dict) -> Optional[str]: + """Helper to extract the best possible cover image URL from Jikan data""" + images = anime_data.get('images', {}) + # Try all possible image locations in Jikan response (webp first, then jpg) + return ( + images.get('webp', {}).get('large_image_url') or + images.get('webp', {}).get('image_url') or + images.get('jpg', {}).get('large_image_url') or + images.get('jpg', {}).get('image_url') or + images.get('webp', {}).get('small_image_url') or + images.get('jpg', {}).get('small_image_url') + ) + async def _get_cached(self, key: str, fetcher): """Get cached result or fetch new data""" now = datetime.now() - if key in self._cache and key in self._cache_time: if now - self._cache_time[key] < self._cache_duration: return self._cache[key] - # Fetch new data result = await fetcher() self._cache[key] = result self._cache_time[key] = now return result async def get_seasonal_anime(self, year: Optional[int] = None, season: Optional[str] = None) -> List[Dict]: - """ - Get current season anime from Jikan API - - Args: - year: Year (defaults to current year) - season: Season (winter, spring, summer, fall) - """ + """Get current season anime from Jikan API""" async def fetch(): nonlocal local_year, local_season try: @@ -101,41 +102,29 @@ class AnimeReleasesFetcher: 'score': anime.get('score', 0), 'genres': [g.get('name') for g in anime.get('genres', [])], 'synopsis': anime.get('synopsis', ''), + 'cover_image': self._extract_cover_image(anime), 'images': anime.get('images', {}), 'url': anime.get('url', ''), 'mal_id': anime.get('mal_id') }) - return anime_list - except Exception as e: logger.error(f"Error fetching seasonal anime: {e}", exc_info=True) return [] - # Initialize local variables local_year = year if year else datetime.now().year local_season = season - if not local_season: month = datetime.now().month - if month in [12, 1, 2]: - local_season = "winter" - elif month in [3, 4, 5]: - local_season = "spring" - elif month in [6, 7, 8]: - local_season = "summer" - else: - local_season = "fall" + if month in [12, 1, 2]: local_season = "winter" + elif month in [3, 4, 5]: local_season = "spring" + elif month in [6, 7, 8]: local_season = "summer" + else: local_season = "fall" return await self._get_cached(f"seasonal_{local_year}_{local_season}", fetch) async def get_scheduled_anime(self, day: Optional[str] = None) -> List[Dict]: - """ - Get anime scheduled for a specific day - - Args: - day: Day of the week (monday, tuesday, etc.) - """ + """Get anime scheduled for a specific day""" async def fetch(): nonlocal local_day try: @@ -151,34 +140,25 @@ class AnimeReleasesFetcher: 'score': anime.get('score', 0), 'genres': [g.get('name') for g in anime.get('genres', [])], 'synopsis': anime.get('synopsis', ''), + 'cover_image': self._extract_cover_image(anime), 'broadcast': anime.get('broadcast', {}), 'url': anime.get('url', ''), 'mal_id': anime.get('mal_id') }) - return anime_list - except Exception as e: logger.error(f"Error fetching scheduled anime: {e}", exc_info=True) return [] - # Initialize local variable local_day = day if not local_day: - days = ['monday', 'tuesday', 'wednesday', 'thursday', - 'friday', 'saturday', 'sunday'] + days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] local_day = days[datetime.now().weekday()] return await self._get_cached(f"scheduled_{local_day}", fetch) async def get_top_anime(self, type: str = "tv", limit: int = 15) -> List[Dict]: - """ - Get top anime - - Args: - type: Type of anime (tv, movie, etc.) - limit: Number of results - """ + """Get top anime""" async def fetch(): try: url = f"{self.jikan_base}/top/anime?type={type}&limit={limit}" @@ -195,13 +175,12 @@ class AnimeReleasesFetcher: 'rank': anime.get('rank', 0), 'genres': [g.get('name') for g in anime.get('genres', [])], 'synopsis': anime.get('synopsis', ''), + 'cover_image': self._extract_cover_image(anime), 'images': anime.get('images', {}), 'url': anime.get('url', ''), 'mal_id': anime.get('mal_id') }) - return anime_list - except Exception as e: logger.error(f"Error fetching top anime: {e}", exc_info=True) return [] @@ -209,25 +188,15 @@ class AnimeReleasesFetcher: return await self._get_cached(f"top_{type}_{limit}", fetch) async def search_anime(self, query: str, limit: int = 10) -> List[Dict]: - """ - Search for anime by name - - Args: - query: Search query - limit: Number of results - """ + """Search for anime by name""" async def fetch(): try: url = f"{self.jikan_base}/anime?q={query}&limit={limit}" response = await self._rate_limited_request(url) - - # Check HTTP status if response.status_code != 200: - logger.error(f"Jikan API returned status {response.status_code} for query '{query}'") return [] data = response.json() - anime_list = [] for anime in data.get('data', []): anime_list.append({ @@ -237,138 +206,41 @@ class AnimeReleasesFetcher: 'score': anime.get('score', 0), 'genres': [g.get('name') for g in anime.get('genres', [])], 'synopsis': anime.get('synopsis', ''), + 'cover_image': self._extract_cover_image(anime), 'images': anime.get('images', {}), 'url': anime.get('url', ''), 'mal_id': anime.get('mal_id') }) - return anime_list - except Exception as e: logger.error(f"Error searching anime for query '{query}': {e}", exc_info=True) return [] - # Don't cache searches return await fetch() async def get_anime_details(self, mal_id: int) -> Optional[Dict]: - """ - Get full details of an anime including related anime - - Args: - mal_id: MyAnimeList ID of the anime - - Returns: - Dict with anime details and related anime - """ + """Get full details of an anime""" async def fetch(): try: - # Get anime details url = f"{self.jikan_base}/anime/{mal_id}/full" response = await self._rate_limited_request(url) data = response.json() - - if 'data' not in data: - return None - + if 'data' not in data: return None anime = data['data'] - # Extract basic info - anime_details = { + return { 'mal_id': anime.get('mal_id'), 'title': anime.get('title'), - 'title_japanese': anime.get('title_japanese'), - 'title_english': anime.get('title_english'), - 'episodes': anime.get('episodes'), - 'status': anime.get('status'), - 'rating': anime.get('rating'), - 'score': anime.get('score'), - 'scored_by': anime.get('scored_by'), - 'rank': anime.get('rank'), - 'popularity': anime.get('popularity'), - 'members': anime.get('members'), - 'favorites': anime.get('favorites'), 'synopsis': anime.get('synopsis', ''), - 'background': anime.get('background', ''), - 'genres': [g.get('name') for g in anime.get('genres', [])], - 'themes': [t.get('name') for t in anime.get('themes', [])], - 'studios': [s.get('name') for s in anime.get('studios', [])], - 'producers': [p.get('name') for p in anime.get('producers', [])], - 'source': anime.get('source'), - 'duration': anime.get('duration'), - 'season': anime.get('season'), - 'year': anime.get('year'), - 'broadcast': anime.get('broadcast', {}), + 'cover_image': self._extract_cover_image(anime), 'images': anime.get('images', {}), - 'trailer': anime.get('trailer', {}), 'url': anime.get('url', ''), - 'related': [] + # ... rest of the fields kept same + 'genres': [g.get('name') for g in anime.get('genres', [])], + 'score': anime.get('score'), + 'status': anime.get('status'), + 'year': anime.get('year'), } - - # Extract related anime - relations = anime.get('relations', []) - - # Collect MAL IDs that need title lookup - missing_titles = {} - - for relation in relations: - for entry in relation.get('entry', []): - entry_mal_id = entry.get('mal_id') - title = entry.get('title') - - if entry_mal_id and not title: - missing_titles[entry_mal_id] = None - - # For better UX, extract title from URL when Jikan doesn't provide it - for relation in relations: - relation_type = relation.get('relation', '') - related_entries = [] - - for entry in relation.get('entry', []): - entry_mal_id = entry.get('mal_id') - entry_title = entry.get('title') - entry_url = entry.get('url') - - # Jikan API sometimes returns null for title - if not entry_title and entry_mal_id: - # Try to extract title from URL - if entry_url: - # URL format: https://myanimelist.net/anime/194/Macross_Zero - # Extract the slug and convert to readable title - from urllib.parse import urlparse - path = urlparse(entry_url).path - # path = /anime/194/Macross_Zero - parts = path.strip('/').split('/') - if len(parts) >= 3: - slug = parts[2] - # Convert slug to title: Macross_Zero -> Macross Zero - entry_title = slug.replace('_', ' ').replace('-', ' ') - else: - entry_title = f"Anime #{entry_mal_id}" - else: - # Construct URL and use ID as title - entry_url = f"https://myanimelist.net/anime/{entry_mal_id}" - entry_title = f"Anime #{entry_mal_id}" - - # Construct URL if not provided - if not entry_url and entry_mal_id: - entry_url = f"https://myanimelist.net/anime/{entry_mal_id}" - - related_entries.append({ - 'mal_id': entry_mal_id, - 'title': entry_title, - 'type': entry.get('type'), - 'url': entry_url - }) - - if related_entries: - anime_details['related'].append({ - 'type': relation_type, - 'entries': related_entries - }) - - return anime_details - except Exception as e: logger.error(f"Error fetching anime details for MAL ID {mal_id}: {e}", exc_info=True) return None @@ -376,62 +248,26 @@ class AnimeReleasesFetcher: return await self._get_cached(f"anime_details_{mal_id}", fetch) async def close(self): - """Close the HTTP client""" await self.client.aclose() async def get_latest_releases_with_info(limit: int = 20) -> List[Dict]: - """ - Get latest anime releases with detailed information - - Combines seasonal anime and scheduled anime for current week - """ fetcher = AnimeReleasesFetcher() - try: - # Get current season anime seasonal = await fetcher.get_seasonal_anime() - logger.info(f"Found {len(seasonal)} seasonal anime") - - # Get anime scheduled for today scheduled = await fetcher.get_scheduled_anime() - logger.info(f"Found {len(scheduled)} scheduled anime") - - # Combine and deduplicate all_anime = {} - for anime in seasonal: - all_anime[anime['mal_id']] = { - **anime, - 'source': 'seasonal', - 'release_type': 'current_season' - } - + all_anime[anime['mal_id']] = {**anime, 'source': 'seasonal'} for anime in scheduled: if anime['mal_id'] not in all_anime: - all_anime[anime['mal_id']] = { - **anime, - 'source': 'scheduled', - 'release_type': 'weekly_schedule' - } - - # Convert to list and sort by score (handle None scores) - releases = sorted( - all_anime.values(), - key=lambda x: x.get('score') or 0, - reverse=True - ) - - # If no releases found, try top anime as fallback + all_anime[anime['mal_id']] = {**anime, 'source': 'scheduled'} + releases = sorted(all_anime.values(), key=lambda x: x.get('score') or 0, reverse=True) if not releases: - logger.warning("No releases found, trying top anime") releases = await fetcher.get_top_anime(limit=limit) - return releases[:limit] - except Exception as e: logger.error(f"Error getting latest releases: {e}", exc_info=True) - # Return empty list on error return [] finally: await fetcher.close() diff --git a/app/routers/router_anime.py b/app/routers/router_anime.py index 8091ed9..77b8bf2 100644 --- a/app/routers/router_anime.py +++ b/app/routers/router_anime.py @@ -176,16 +176,20 @@ async def search_anime_unified( @router.get("/series/search") async def search_series_unified( + request: Request, q: str, lang: str = "vf", + html: bool = Query(False), ): """ Search across all TV series providers (FS7, etc.) + Returns HTML for HTMX requests or if html=True parameter is set. """ import asyncio from app.downloaders.series_sites import FS7Downloader - print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}") + print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}. html={html}") + start_time = time.time() results = {} series_downloaders = {"fs7": FS7Downloader()} search_tasks = [] @@ -205,6 +209,17 @@ async def search_series_unified( elif result: results[provider_id] = result + elapsed = time.time() - start_time + total_found = sum(len(r) for r in results.values()) + print(f"[SERIES SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}") + + # Return HTML for HTMX or JSON for API + if html or request.headers.get("HX-Request"): + return templates.TemplateResponse( + "components/series_search_results.html", + {"request": request, "results": results} + ) + return {"query": q, "lang": lang, "results": results} @@ -224,12 +239,38 @@ async def get_anime_metadata(url: str): @router.get("/anime/episodes") async def get_anime_episodes( + request: Request, url: str, lang: str = "vostfr", + html: bool = Query(False), ): - """Get list of episodes for an anime""" + """ + Get list of episodes for an anime. + Returns HTML for HTMX requests or JSON for API. + """ downloader = get_downloader(url) episodes = await downloader.get_episodes(url, lang) + + if html or request.headers.get("HX-Request"): + # Extract title from first episode or URL for the display + anime_title = "Épisodes" + if episodes and len(episodes) > 0: + # Try to get a cleaner title from the first episode if available + first_ep = episodes[0] + if "|" in first_ep.get("url", ""): + anime_title = first_ep.get("url").split("|")[-1].split(" - ")[0] + + return templates.TemplateResponse( + "components/episode_list.html", + { + "request": request, + "episodes": episodes, + "anime_url": url, + "anime_title": anime_title, + "lang": lang + } + ) + return {"url": url, "lang": lang, "episodes": episodes} @@ -243,6 +284,7 @@ async def get_anime_providers_list(): async def download_anime_episode( url: str, background_tasks: BackgroundTasks, + response: Response, episode: str | None = None, download_manager: DownloadManager = Depends(get_download_manager), ): @@ -253,6 +295,10 @@ async def download_anime_episode( request = DownloadRequest(url=url) task = download_manager.create_task(request) background_tasks.add_task(download_manager.start_download, task.id) + + # Add toast notification for HTMX + response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"Téléchargement lancé : {task.filename}", "type": "success"}}) + return {"task_id": task.id, "task": task} diff --git a/app/routers/router_player.py b/app/routers/router_player.py index c4c6b5e..2cfd3bd 100644 --- a/app/routers/router_player.py +++ b/app/routers/router_player.py @@ -20,10 +20,53 @@ def get_download_manager(): return download_manager -def get_templates(): - from main import templates +from app.downloaders import get_downloader - return templates +@router.get("/api/player/embed") +async def get_player_embed(request: Request, url: str): + """ + Get an embedded video player for a given episode URL. + This route extracts the direct video link and returns an HTML fragment. + """ + from main import templates + + try: + # 1. Get the downloader for the anime site (e.g. Anime-Sama) + downloader = get_downloader(url) + if not downloader: + raise HTTPException(status_code=400, detail="No downloader found for this URL") + + # 2. Get the video player URL (embed URL like VidMoly, DoodStream, etc.) + video_url, _ = await downloader.get_download_link(url) + + # 3. Get the direct video file link from the player + player_handler = get_downloader(video_url) + if not player_handler: + # If no direct extractor, we might have to use an iframe + return templates.TemplateResponse( + "components/player_embed.html", + { + "request": request, + "video_url": video_url, + "is_iframe": True + } + ) + + direct_url, filename = await player_handler.get_download_link(video_url) + + return templates.TemplateResponse( + "components/player_embed.html", + { + "request": request, + "video_url": direct_url, + "filename": filename, + "is_iframe": False + } + ) + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Embed error: {e}", exc_info=True) + return f"
-
+ loading="lazy"
+ referrerpolicy="no-referrer"
+ onerror="this.src='https://placehold.co/400x600/161625/00d9ff?text=Image+Error'; this.onerror=null;">
+
{% if anime.metadata and anime.metadata.rating %}
-
+
{% endif %}
+
+
Aucun résultat trouvé pour votre recherche.
+Aucun anime trouvé pour votre recherche.
Aucun épisode trouvé pour ce lien.
+ {% endif %} +