From 3dc5dd8fe935d136eac0dd254d22a610c7666895 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 28 Mar 2026 00:14:31 +0000 Subject: [PATCH] feat: fix auth, provider health checks, search, and redesign UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix register/login: dict-style access on UserTable ORM objects - Fix HTMX auth: inject JWT token in all HTMX request headers - Fix FS7 search: use DLE AJAX endpoint /engine/ajax/search.php - Fix ZT search: use ?p=series&search=QUERY (not DLE format) - Fix provider health: load hardcoded providers + domain manager - Add self.id to all anime/series providers - Redesign homepage: Netflix-style horizontal scroll cards (.hc) - Redesign search results: grouped by title, poster + synopsis + 3 buttons - Add Télécharger dropdown: season download + episode picker - Fix navbar CSS: restore .tabs flex layout, remove orphan rules - Fix HTMX spinner: remove inline display:none, use CSS indicator - Add AGENTS.md files across project for developer documentation --- AGENTS.md | 481 ++----- app/AGENTS.md | 47 + app/auth.py | 98 +- app/downloaders/AGENTS.md | 49 + app/downloaders/anime_sites/AGENTS.md | 46 +- app/downloaders/anime_sites/animesama.py | 1163 ++++++++++------- app/downloaders/anime_sites/animeultime.py | 251 ++-- app/downloaders/anime_sites/frenchmanga.py | 172 +-- app/downloaders/anime_sites/nekosama.py | 206 +-- app/downloaders/anime_sites/vostfree.py | 141 +- app/downloaders/series_sites/fs7.py | 267 ++-- .../series_sites/zonetelechargement.py | 231 ++-- app/downloaders/video_players/AGENTS.md | 45 +- app/models/AGENTS.md | 45 + app/providers.py | 110 +- app/providers_manager.py | 102 +- app/routers/AGENTS.md | 37 + app/routers/router_anime.py | 135 +- app/routers/router_auth.py | 70 +- app/routers/router_settings.py | 113 +- app/routers/router_watchlist.py | 112 +- main.py | 21 +- static/css/style.css | 156 +-- static/js/AGENTS.md | 56 + static/js/auth.js | 9 +- static/js/downloads.js | 1 - templates/base.html | 17 +- templates/components/anime_card.html | 73 +- .../components/anime_search_results.html | 166 ++- templates/components/header.html | 3 +- templates/components/home_section.html | 13 +- templates/components/login_prompt.html | 4 + templates/components/series_card.html | 66 +- .../components/series_search_results.html | 140 +- templates/index.html | 4 +- tests/AGENTS.md | 74 +- 36 files changed, 2735 insertions(+), 1989 deletions(-) create mode 100644 app/AGENTS.md create mode 100644 app/downloaders/AGENTS.md create mode 100644 app/models/AGENTS.md create mode 100644 app/routers/AGENTS.md create mode 100644 static/js/AGENTS.md create mode 100644 templates/components/login_prompt.html diff --git a/AGENTS.md b/AGENTS.md index 1038681..110132b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,393 +1,156 @@ -# AGENTS.md - Agentic Coding Guidelines +# AGENTS.md — Ohm Stream Downloader -This file provides guidance for AI agents working in this repository. +FastAPI anime/series downloader with HTMX+Alpine.js frontend, SQLModel/SQLite DB, +3-tier scraper architecture, JWT auth, Sonarr webhooks, and auto-download scheduler. -## Quick Start +## COMMANDS ```bash -# Setup -python3 -m venv venv && source venv/bin/activate -pip install -r requirements.txt - -# Run dev server +# Dev server uvicorn main:app --reload --host 0.0.0.0 --port 3000 -``` -## Build, Lint & Test Commands +# --- Tests (pytest, configured in pytest.ini, asyncio_mode=auto) --- -### Running Tests +pytest # All tests (coverage + verbose by default) +pytest -m "unit" # Fast unit tests only +pytest -m "integration" # API integration tests +pytest -m "not slow" # CI default — excludes slow tests +pytest -m "network" # Tests requiring network access -```bash -# All tests -pytest - -# With coverage (HTML report in htmlcov/) -pytest --cov=app --cov-report=html - -# Unit only (fast) -pytest -m "unit" - -# Integration tests only -pytest -m "integration" - -# Exclude slow tests -pytest -m "not slow" - -# Exclude network tests (mocked only) -pytest -m "not network" - -# Verbose with print debugging -pytest -v -s - -# Generate HTML report -pytest --html=report.html --self-contained-html - -# Timeout per test (seconds) -pytest --timeout=30 -``` - -### Running Single Tests - -```bash -# Specific file +# Single file / class / test pytest tests/test_sonarr.py -v - -# Specific class pytest tests/test_sonarr.py::TestSonarrHandler -v - -# Specific test pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v -# Pattern match -pytest -k "test_download" -v +# Debug +pytest -s # Show print() output +pytest --cov=app --cov-report=html # HTML coverage report in htmlcov/ + +# --- Lint & Format (ruff) --- + +ruff check app/ # Lint +ruff format --check app/ # Format check (CI enforces this) +ruff format app/ # Auto-format + +# --- Type Check --- + +mypy app/ --ignore-missing-imports # Type check (CI enforces) + +# --- DB Migrations --- + +alembic revision --autogenerate -m "description" +alembic upgrade head + +# --- Frontend (optional) --- + +npm test # Vitest JS tests +npx playwright test # E2E browser tests ``` -## Code Style +## CODE STYLE -### Imports (PEP 8 order) -1. Standard library (`os`, `json`, `asyncio`) -2. Third-party (`httpx`, `beautifulsoup4`, `fastapi`) -3. Local app (`app.config`, `app.utils`) - -```python -import os -import asyncio -from typing import Optional - -import httpx -from fastapi import APIRouter, HTTPException - -from app.config import get_settings -from app.models import DownloadTask, DownloadStatus -``` +### Imports +Three groups separated by blank lines: stdlib → third-party → local (`app.*`). +Always absolute imports (no relative). No wildcard imports (`from X import *` enforced). ### Formatting -- **Line length**: 120 chars max -- **Indentation**: 4 spaces -- **Blank lines**: 2 between top-level, 1 between inline +PEP 8, 120 chars max line length. Single quotes for strings, double quotes for docstrings. +Ruff handles linting and formatting (no local config — CI-only). -### Type Annotations -- Use explicit types -- Use `Optional[X]` not `X | None` -- Use `list[X]`, `dict[X, Y]` +### Types +Explicit type hints on all function signatures and return types. +Use `Optional[X]` from `typing`. Use `list[str]` / `dict[str, X]` (lowercase generics). +Pydantic models for all API schemas. Return type annotations required on public methods. -```python -# Good -async def get_download_link(url: str, target_filename: Optional[str] = None) -> tuple[str, str]: - results: list[dict[str, str]] = [] - -# Avoid -async def get_download_link(url, target_filename=None): - results = [] -``` - -### Naming Conventions - -| Element | Convention | Example | -|---------|------------|---------| -| Modules | snake_case | `download_manager.py` | -| Classes | PascalCase | `DownloadManager` | -| Functions | snake_case | `get_download_link()` | -| Constants | UPPER_SNAKE | `MAX_PARALLEL_DOWNLOADS` | -| Variables | snake_case | `download_task` | -| Enums | PascalCase | `DownloadStatus` | -| Enum values | UPPER_SNAKE | `DownloadStatus.PENDING` | - -### Async/Await -- Always use for I/O operations -- Close clients properly to avoid leaks - -```python -async def close(self): - await self.client.aclose() -``` +### Naming +- `snake_case` for functions, variables, constants +- `PascalCase` for classes and enums +- `UPPER_SNAKE_CASE` for enum values (`DownloadStatus.PENDING`) +- `logger = logging.getLogger(__name__)` at module level +- `_` prefix for private methods (`_fetch_page`, `_sanitize`) +- `get_*` for factory functions (`get_downloader`, `get_anime_site`) ### Error Handling -- Use try/except for recoverable errors -- Raise specific exceptions (`HTTPException`, `ValueError`) -- Never use empty except blocks -- Log errors appropriately +- `HTTPException` for API errors with proper status codes +- `raise ValueError()` for business logic validation +- `try/except` with logging — never bare `except:` (known tech debt exists) +- `response.raise_for_status()` for HTTP errors +- Never return `None` for missing URLs from downloaders — raise an exception -```python -try: - result = await client.get(url) -except httpx.TimeoutException: - logger.warning(f"Request timeout for {url}") - raise HTTPException(status_code=504, detail="Request timeout") +### Docstrings +Triple double quotes `"""`. Module docstrings describe purpose. Class docstrings describe +responsibility. Complex methods use Args/Returns sections. Not all methods require docstrings. + +## ARCHITECTURE + +``` +main.py # App entry, middleware, startup, router registration +app/ +├── routers/ # 11 APIRouter modules (one per feature domain) +├── downloaders/ # 3-tier: anime_sites/ → series_sites/ → video_players/ +├── models/ # Pydantic/SQLModel (Base → Table → Schema pattern) +├── config.py # Pydantic Settings from .env +├── database.py # SQLModel engine (created at import time) +├── download_manager.py # Async queue, semaphore-based parallelism +├── auth.py # JWT + bcrypt, SQLModel user storage +├── providers.py # ANIME_PROVIDERS, SERIES_PROVIDERS, FILE_HOSTS registries +└── utils.py # sanitize_filename(), is_safe_filename() +templates/ # Jinja2 + HTMX + Alpine.js +static/js/ # Vanilla ES modules (no build step) +tests/ # pytest suite (conftest.py has shared fixtures) +config/ # Runtime JSON files (users, watchlist, sonarr) +alembic/ # DB migrations ``` -### File Operations -- Always sanitize filenames: `app.utils.sanitize_filename()` -- Validate paths: `app.utils.is_safe_filename()` +## KEY CONVENTIONS -### Testing -- Use pytest with pytest-asyncio -- Mark tests: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.slow`, `@pytest.mark.network` -- Tests in `test_api.py` are auto-marked as integration, others as unit -- Use fixtures from `tests/conftest.py` +- **URL pipe format**: `video_url|anime_page_url|episode_title` — metadata preserved through download pipeline +- **Module-level init**: `database.py` creates engine on import; `main.py` creates `download_manager` on import +- **Async everywhere**: Always `async/await` for I/O — use `httpx.AsyncClient`, never `requests` +- **Factory pattern**: `get_downloader(url)` routes anime → series → video player → generic +- **Router deps**: `Depends(lambda: download_manager)`, `Depends(get_current_user_from_token)`, `Depends(lambda: templates)` +- **Dual storage**: Some features use JSON files (legacy) + SQLModel tables (newer) +- **Frontend**: No JS build step. HTMX for server interactions, Alpine.js for client state, Plyr.io for video +- **Circular import avoidance**: `episode_checker.py` uses lazy init (`set_download_manager()`) -```python -@pytest.mark.unit -@pytest.mark.asyncio -async def test_download_manager(): - manager = DownloadManager(max_parallel=3) - assert manager.max_parallel == 3 +## ANTI-PATTERNS (DO NOT) -# Mark slow tests -@pytest.mark.slow -async def test_full_download_flow(): - ... +- Use sync `requests` — always `httpx.AsyncClient` +- Return `None` for missing URLs from downloaders — raise an exception +- Skip `sanitize_filename()` on extracted filenames — path traversal risk +- Forget `await self.close()` in downloaders — resource leak +- Hardcode User-Agent in individual players — use base class headers +- Use `from X import *` — always explicit imports +- Import `download_manager` from `main.py` in app/ modules — causes circular imports +- Store secrets in `config/*.json` — use `.env` +- Use `as any`, `@ts-ignore` to suppress type errors (if adding TS) -# Mark tests requiring network -@pytest.mark.network -async def test_external_api(): - ... -``` +## TEST CONVENTIONS -### Security -- Never hardcode secrets - use environment variables -- Validate all inputs (URLs, filenames) -- Use HMAC for webhook verification when configured -- Limit CORS origins - never use `*` in production +- `tests/` directory with `conftest.py` for shared fixtures +- Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.network`, `@pytest.mark.slow` +- `asyncio_mode = auto` — async test functions run without explicit marker +- Test naming: `test__` in `Test*` classes +- 300s timeout configured in pytest.ini; `testpaths = tests` +- Legacy test files at project root: `test_watchlist_simple.py`, `test_watchlist_e2e.py` -## Architecture Patterns +## ADDING NEW PROVIDERS -### Three-Tier Downloader Architecture +**Video player**: Create in `app/downloaders/video_players/`, inherit `BaseVideoPlayer`, +implement `can_handle()` + `get_download_link(url, target_filename=None)`, register in +`__init__.py`, add to `FILE_HOSTS` in `providers.py`. -The project uses a three-tier downloader system: +**Anime/series site**: Create in `app/downloaders/anime_sites/` or `series_sites/`, inherit +base class, implement `search_anime()` + `get_episodes()` + `get_anime_metadata()` + +`get_download_link()`, register in `__init__.py`, add to `providers.py`. -1. **Anime Catalogs** (`app/downloaders/anime_sites/`) - - `animesama.py` - Anime-Sama (primary) - - `animeultime.py` - Anime-Ultime - - `nekosama.py` - Neko-Sama - - `vostfree.py` - Vostfree - - `frenchmanga.py` - French-Manga +## NOTES -2. **Series Catalogs** (`app/downloaders/series_sites/`) - - `fs7.py` - French Stream - -3. **Video Players** (`app/downloaders/video_players/`) - - `sibnet.py`, `doodstream.py`, `vidmoly.py`, `uqload.py` - - `uptobox.py`, `unfichier.py`, `rapidfile.py` - - `sendvid.py`, `lpayer.py`, `vidzy.py`, `luluv.py` - - `oneupload.py`, `smoothpre.py` - -Each tier has a base class and factory pattern. When adding providers: -1. Inherit from appropriate base class (`base.py`) -2. Implement required methods (`search_anime`, `get_episodes`, `get_download_link`) -3. Register in `app/providers.py` -4. Add URL detection patterns - -**URL Convention**: Pipe-separated format preserves metadata: -``` -video_url|anime_page_url|episode_title -``` - -### Core Modules - -| Module | Purpose | -|--------|---------| -| `app/watchlist.py` | Episode tracking & auto-download | -| `app/auto_download_scheduler.py` | APScheduler for periodic checks | -| `app/episode_checker.py` | New episode detection | -| `app/sonarr_handler.py` | Sonarr webhook integration | -| `app/recommendation_engine.py` | Personalized anime recommendations | -| `app/favorites.py` | User favorites management | -| `app/auth.py` | JWT authentication | -| `app/download_manager.py` | Download queue management | - -## Key Files - -| File | Purpose | -|------|---------| -| `main.py` | FastAPI app, all API endpoints | -| `app/config.py` | Pydantic Settings configuration | -| `app/download_manager.py` | Download queue & task management | -| `app/utils.py` | `sanitize_filename`, `is_safe_filename` | -| `app/auth.py` | JWT auth, user management | -| `app/providers.py` | Provider definitions & URL detection | -| `app/models/__init__.py` | Core Pydantic models | -| `app/models/watchlist.py` | Watchlist models | -| `app/models/sonarr.py` | Sonarr integration models | -| `app/models/auth.py` | Authentication models | - -## Frontend Architecture - -### JavaScript Modules (`static/js/`) -| Module | Purpose | -|--------|---------| -| `main.js` | Application entry point | -| `api.js` | API client functions | -| `auth.js` | Authentication handling | -| `tabs.js` | Tab navigation | -| `anime.js` | Anime search & display | -| `anime-details.js` | Anime detail views | -| `watchlist.js` | Watchlist API calls | -| `watchlist-ui.js` | Watchlist UI rendering | -| `downloads.js` | Download management UI | -| `recommendations.js` | Recommendations display | -| `series-search.js` | TV series search | -| `utils.js` | Utility functions | - -### Templates (`templates/`) -| Template | Purpose | -|----------|---------| -| `base.html` | Base layout with CSS/JS imports | -| `index.html` | Main SPA interface | -| `login.html` | Login/register page | -| `watchlist.html` | Watchlist management page | -| `player.html` | Video player page | -| `components/` | Reusable HTML components | - -## Configuration - -- Use `.env` from `.env.example` -- `JWT_SECRET_KEY` must change in production -- Config files stored in `config/`: - - `users.json` - User database - - `watchlist.json` - Watchlist data - - `watchlist_settings.json` - Auto-download settings - - `sonarr.json` - Sonarr integration config - - `sonarr_mappings.json` - Series to anime mappings - -## API Endpoints Overview - -### Authentication -- `POST /api/auth/register` - Register new user -- `POST /api/auth/login` - Login, get JWT token -- `GET /api/auth/me` - Get current user info -- `POST /api/auth/logout` - Logout (client-side) - -### Downloads -- `POST /api/download` - Create download task -- `GET /api/downloads` - List all downloads -- `GET /api/download/{task_id}` - Get download status -- `POST /api/download/{task_id}/pause` - Pause download -- `POST /api/download/{task_id}/resume` - Resume download -- `DELETE /api/download/{task_id}` - Cancel/delete download -- `GET /api/download/{task_id}/file` - Download completed file - -### Anime Search & Metadata -- `GET /api/anime/search` - Search across all anime providers -- `GET /api/series/search` - Search TV series providers -- `GET /api/anime/metadata` - Get detailed anime metadata -- `GET /api/anime/episodes` - Get episode list -- `GET /api/anime/seasons` - Get available seasons -- `POST /api/anime/download-season` - Download all episodes - -### Watchlist -- `GET /api/watchlist` - List watchlist items -- `POST /api/watchlist` - Add to watchlist -- `PUT /api/watchlist/{item_id}` - Update watchlist item -- `DELETE /api/watchlist/{item_id}` - Remove from watchlist -- `GET /api/watchlist/settings` - Get auto-download settings -- `PUT /api/watchlist/settings` - Update settings -- `POST /api/watchlist/check` - Trigger manual episode check - -### Favorites -- `GET /api/favorites` - List favorites -- `POST /api/favorites` - Add to favorites -- `DELETE /api/favorites/{anime_id}` - Remove from favorites -- `POST /api/favorites/toggle` - Toggle favorite status - -### Recommendations -- `GET /api/recommendations` - Get personalized recommendations -- `GET /api/releases/latest` - Get latest releases -- `GET /api/releases/seasonal` - Get seasonal anime - -### Sonarr Integration -- `POST /api/sonarr/webhook` - Receive Sonarr webhooks -- `GET /api/sonarr/mappings` - List Sonarr mappings -- `POST /api/sonarr/mappings` - Create mapping -- `DELETE /api/sonarr/mappings/{series_id}` - Delete mapping - -## Dependencies - -### Core -- `fastapi` - Web framework -- `uvicorn` - ASGI server -- `httpx` - Async HTTP client -- `aiohttp` - Alternative HTTP client -- `pydantic` / `pydantic-settings` - Data validation & settings - -### Scraping & Parsing -- `beautifulsoup4` - HTML parsing -- `lxml` - XML/HTML parser -- `jieba` - Chinese text segmentation - -### Authentication -- `python-jose` - JWT handling -- `passlib[bcrypt]` - Password hashing - -### Scheduler -- `apscheduler` - Job scheduling for auto-downloads - -### Cryptography -- `pycryptodome` - AES decryption for video players - -### Testing -- `pytest` + `pytest-asyncio` - Async test support -- `pytest-cov` - Coverage reporting -- `pytest-mock` - Mocking utilities -- `pytest-timeout` - Test timeout protection -- `pytest-html` - HTML test reports - - -## CI/CD - -### GitHub Actions - -This project uses GitHub Actions for continuous integration. The workflow is defined in `.github/workflows/ci.yml`. - -**Workflow Features:** -- Runs on push and pull requests to `main` and `dev` branches -- Tests on Python 3.11 and 3.12 -- Excludes slow tests by default (`-m "not slow"`) -- Generates HTML coverage reports -- Runs linting with ruff -- Runs type checking with mypy - -**Artifacts:** -- Coverage reports are uploaded as artifacts after each run -- Access via the "Actions" tab on GitHub - -**Running locally:** -```bash -# Run tests (excluding slow tests) -pytest -m "not slow" --cov=app --cov-report=html - -# Run all tests including slow ones -pytest - -# Run only unit tests -pytest -m "unit" - -# Run only integration tests -pytest -m "integration" - -# Run linting -pip install ruff && ruff check app/ - -# Run type checking -pip install mypy && mypy app/ -``` +- Python 3.11+, CI tests on 3.11 and 3.12 +- No `pyproject.toml` — uses `requirements.txt` with exact version pinning +- `GEMINI.md` and `CLAUDE.md` exist with tool-specific instructions (merge if conflicting) +- French-language project (animes, séries, VOSTFR) but all code and comments in English +- ~20 empty `except:` blocks in downloaders/tests — known tech debt +- `JWT_SECRET_KEY` min 32 chars, default rejected at startup; generate via `Settings.generate_secret()` +- Sub-AGENTS.md files exist in `app/`, `app/routers/`, `app/downloaders/`, `app/models/`, + `app/downloaders/anime_sites/`, `app/downloaders/video_players/` for deeper context diff --git a/app/AGENTS.md b/app/AGENTS.md new file mode 100644 index 0000000..cfda7db --- /dev/null +++ b/app/AGENTS.md @@ -0,0 +1,47 @@ +# App Core (app/) + +## OVERVIEW +FastAPI application core — config, auth, download management, providers, and business logic. Routes are in `routers/`, scrapers in `downloaders/`, models in `models/`. + +## STRUCTURE +``` +app/ +├── config.py # Pydantic Settings (loads .env) +├── database.py # SQLModel engine (created at import time) +├── download_manager.py # Async download queue (semaphore-based) +├── auth.py # JWT + bcrypt, JSON-backed UserManager +├── providers.py # ANIME_PROVIDERS, FILE_HOSTS registries +├── utils.py # sanitize_filename(), is_safe_filename() +├── watchlist.py # WatchlistManager (JSON + SQLModel hybrid) +├── episode_checker.py # New episode detection for watchlist +├── auto_download_scheduler.py # APScheduler periodic checks +├── sonarr_handler.py # Sonarr webhook processing +├── favorites.py # FavoritesManager (JSON-backed) +├── recommendation_engine.py # Download history analysis +├── recommendations.py # Latest releases fetcher +└── kitsu_api.py # Kitsu anime metadata API +``` + +## WHERE TO LOOK + +| Need | File | Notes | +|------|------|-------| +| Add env var | `config.py` | Add to Settings class, update `.env.example` | +| Add provider domain | `providers.py` | ANIME_PROVIDERS or FILE_HOSTS dict | +| Download queue logic | `download_manager.py` | Semaphore-limited parallel downloads | +| Auth/token logic | `auth.py` | UserManager, JWT create/verify | +| Filename safety | `utils.py` | ALWAYS use sanitize_filename() | + +## CONVENTIONS + +**Circular import avoidance**: `episode_checker.py` uses lazy init (`set_download_manager()`) — called from `main.py:47` after both modules loaded. + +**Dual storage**: Some features use JSON files (favorites, users) + SQLModel tables (watchlist, sonarr mappings). JSON is legacy, SQLModel is newer. + +**Module-level side effects**: `database.py` creates engine on import. `main.py` creates `download_manager` on import (line 44). `restore_completed_downloads()` runs at module level (line 108). + +## ANTI-PATTERNS + +- Do NOT import `download_manager` from `main.py` in other app/ modules — causes circular imports +- Do NOT use `requests` — always `httpx.AsyncClient` +- Do NOT store secrets in `config/*.json` — use `.env` diff --git a/app/auth.py b/app/auth.py index 3872dc7..f7bc99b 100644 --- a/app/auth.py +++ b/app/auth.py @@ -32,7 +32,8 @@ class UserManager: def get_user(self, username: str) -> Optional[UserTable]: """Get user by username""" - from app.models.watchlist import WatchlistItemTable # Force registration + from app.models.watchlist import WatchlistItemTable # Force registration + with Session(engine) as session: statement = select(UserTable).where(UserTable.username == username) return session.exec(statement).first() @@ -44,7 +45,11 @@ class UserManager: return session.exec(statement).first() def create_user( - self, username: str, password: str, email: str = None, full_name: str = None + self, + username: str, + password: str, + email: Optional[str] = None, + full_name: Optional[str] = None, ) -> UserTable: """Create a new user""" with Session(engine) as session: @@ -68,7 +73,7 @@ class UserManager: full_name=full_name, hashed_password=hashed_password, is_active=True, - created_at=datetime.now() + created_at=datetime.now(), ) session.add(user) @@ -105,11 +110,11 @@ class UserManager: db_user = session.get(UserTable, user_id) if not db_user: return None - + for key, value in update_data.items(): if hasattr(db_user, key): setattr(db_user, key, value) - + session.add(db_user) session.commit() session.refresh(db_user) @@ -191,9 +196,10 @@ REFRESH_TOKENS_FILE = "config/refresh_tokens.json" def _load_refresh_tokens() -> Dict[str, dict]: """Load refresh tokens from file""" import json + try: if os.path.exists(REFRESH_TOKENS_FILE): - with open(REFRESH_TOKENS_FILE, 'r', encoding='utf-8') as f: + with open(REFRESH_TOKENS_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.error(f"Error loading refresh tokens: {e}") @@ -203,9 +209,10 @@ def _load_refresh_tokens() -> Dict[str, dict]: def _save_refresh_tokens(tokens: Dict[str, dict]): """Save refresh tokens to file""" import json + try: os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True) - with open(REFRESH_TOKENS_FILE, 'w', encoding='utf-8') as f: + with open(REFRESH_TOKENS_FILE, "w", encoding="utf-8") as f: json.dump(tokens, f, indent=2, ensure_ascii=False, default=str) except Exception as e: logger.error(f"Error saving refresh tokens: {e}") @@ -216,59 +223,60 @@ def _get_jwt_config() -> dict: "SECRET_KEY": settings.jwt_secret_key, "ALGORITHM": settings.jwt_algorithm, "ACCESS_TOKEN_EXPIRE_MINUTES": settings.access_token_expire_minutes, - "REFRESH_TOKEN_EXPIRE_DAYS": 30 + "REFRESH_TOKEN_EXPIRE_DAYS": 30, } + def create_access_refresh_tokens(data: dict) -> tuple[str, str]: """ Create both access and refresh tokens. - + Access token: short-lived (24 hours by default) Refresh token: long-lived (30 days by default) - + Returns: (access_token, refresh_token) """ from jose import jwt import secrets - + jwt_config = _get_jwt_config() - + # Create access token (short-lived) - access_expire = datetime.utcnow() + timedelta(minutes=jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"]) + access_expire = datetime.utcnow() + timedelta( + minutes=jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"] + ) access_data = data.copy() access_data.update({"exp": access_expire, "type": "access"}) access_token = jwt.encode( - access_data, - jwt_config["SECRET_KEY"], - algorithm=jwt_config["ALGORITHM"] + access_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"] ) - + # Create refresh token (long-lived) - refresh_expire = datetime.utcnow() + timedelta(days=jwt_config["REFRESH_TOKEN_EXPIRE_DAYS"]) + refresh_expire = datetime.utcnow() + timedelta( + days=jwt_config["REFRESH_TOKEN_EXPIRE_DAYS"] + ) # Generate a unique token ID token_id = secrets.token_urlsafe(32) refresh_data = { "sub": data["sub"], "token_id": token_id, "exp": refresh_expire, - "type": "refresh" + "type": "refresh", } refresh_token = jwt.encode( - refresh_data, - jwt_config["SECRET_KEY"], - algorithm=jwt_config["ALGORITHM"] + refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"] ) - + # Store refresh token mapping refresh_tokens = _load_refresh_tokens() refresh_tokens[token_id] = { "username": data["sub"], "token_id": token_id, "created_at": datetime.now().isoformat(), - "expires_at": refresh_expire.isoformat() + "expires_at": refresh_expire.isoformat(), } _save_refresh_tokens(refresh_tokens) - + return access_token, refresh_token @@ -279,35 +287,37 @@ def verify_refresh_token(token: str) -> Optional[str]: """ from jose import jwt from jose.exceptions import JWTError - + jwt_config = _get_jwt_config() - + try: - payload = jwt.decode(token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]) - + payload = jwt.decode( + token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]] + ) + # Verify this is a refresh token if payload.get("type") != "refresh": return None - + username = payload.get("sub") token_id = payload.get("token_id") - + if not username or not token_id: return None - + # Check if token exists in storage refresh_tokens = _load_refresh_tokens() stored_token = refresh_tokens.get(token_id) - + if not stored_token: return None - + # Verify token hasn't been revoked or expired if stored_token.get("revoked"): return None - + return username - + except JWTError: return None @@ -319,24 +329,26 @@ def revoke_refresh_token(token: str) -> bool: """ from jose import jwt from jose.exceptions import JWTError - + jwt_config = _get_jwt_config() - + try: - payload = jwt.decode(token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]) + payload = jwt.decode( + token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]] + ) token_id = payload.get("token_id") - + if not token_id: return False - + refresh_tokens = _load_refresh_tokens() if token_id in refresh_tokens: refresh_tokens[token_id]["revoked"] = True refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat() _save_refresh_tokens(refresh_tokens) return True - + return False - + except JWTError: return False diff --git a/app/downloaders/AGENTS.md b/app/downloaders/AGENTS.md new file mode 100644 index 0000000..8944ba1 --- /dev/null +++ b/app/downloaders/AGENTS.md @@ -0,0 +1,49 @@ +# Downloaders (app/downloaders/) + +## OVERVIEW +3-tier scraper architecture: anime catalogs → series catalogs → video players. Factory pattern routes URLs through each tier. + +## STRUCTURE +``` +downloaders/ +├── __init__.py # get_downloader(url) — 3-tier factory + GenericDownloader +├── base.py # Legacy BaseDownloader (kept for compat) +├── anime_sites/ # Anime streaming catalogs (see anime_sites/AGENTS.md) +│ ├── __init__.py # get_anime_site(url) factory +│ ├── base.py # BaseAnimeSite abstract class +│ └── *.py # 5 anime providers +├── series_sites/ # TV series catalogs (see series_sites/AGENTS.md) +│ ├── __init__.py # get_series_site(url) factory +│ ├── base.py # BaseSeriesSite abstract class +│ └── fs7.py # 1 series provider +└── video_players/ # File hosting extractors (see video_players/AGENTS.md) + ├── __init__.py # get_video_player(url) factory + ├── base.py # BaseVideoPlayer abstract class + └── *.py # 13 video player handlers +``` + +## WHERE TO LOOK + +| Need | File | Notes | +|------|------|-------| +| Route URL to downloader | `__init__.py:32` | `get_downloader(url)` tries anime→series→video→generic | +| Add anime provider | `anime_sites/` | Inherit BaseAnimeSite, register in anime_sites/__init__.py | +| Add series provider | `series_sites/` | Inherit BaseSeriesSite, register in series_sites/__init__.py | +| Add video player | `video_players/` | Inherit BaseVideoPlayer, register in video_players/__init__.py | +| Provider domains/icons | `app/providers.py` | Separate from downloader code | + +## CONVENTIONS + +**URL pipe format**: `video_url|anime_page_url|episode_title` — metadata preserved through tiers. Anime/series sites return player URLs (not direct downloads). Video players extract final download links. + +**Factory chain**: `get_downloader()` → `get_anime_site()` → `get_series_site()` → `get_video_player()` → `GenericDownloader`. + +**New provider checklist**: 1) Create .py inheriting base class, 2) Implement required methods, 3) Add to `__init__.py` factory list, 4) Add to `app/providers.py`. + +## ANTI-PATTERNS + +- Do NOT return None from `get_download_link()` — raise Exception +- Do NOT use sync `requests` — always `httpx.AsyncClient` +- Do NOT forget `await self.close()` — causes resource leaks +- Do NOT skip `sanitize_filename()` on extracted filenames +- Do NOT hardcode User-Agent per player — use base class headers diff --git a/app/downloaders/anime_sites/AGENTS.md b/app/downloaders/anime_sites/AGENTS.md index ad34199..1f18308 100644 --- a/app/downloaders/anime_sites/AGENTS.md +++ b/app/downloaders/anime_sites/AGENTS.md @@ -1,4 +1,4 @@ -# Anime Sites Downloaders +# Anime Sites (app/downloaders/anime_sites/) ## OVERVIEW Handlers for French anime streaming catalogs that provide metadata and episode listings, delegating actual video extraction to video player handlers. @@ -7,8 +7,8 @@ Handlers for French anime streaming catalogs that provide metadata and episode l | File | Purpose | |------|---------| -| `base.py` | Abstract `BaseAnimeSite` class defining the interface all anime sites implement | -| `animesama.py` | Primary provider with dynamic domain switching, multiple video player extraction | +| `base.py` | Abstract `BaseAnimeSite` class defining the interface | +| `animesama.py` | Primary provider — dynamic domain switching, multiple video player extraction | | `nekosama.py` | Neko-Sama / Gupy integration (metadata-only, no direct downloads) | | `animeultime.py` | Anime-Ultime catalog handler | | `vostfree.py` | Vostfree catalog handler | @@ -16,26 +16,26 @@ Handlers for French anime streaming catalogs that provide metadata and episode l ## CONVENTIONS -### Interface Contract -Each site must implement four async methods from `BaseAnimeSite`: -- `can_handle(url: str) -> bool` — URL pattern matching -- `search_anime(query, lang) -> list[dict]` — Returns `{title, url, cover_image}` -- `get_episodes(anime_url, lang) -> list[dict]` — Returns `{episode_number, url, title, host}` -- `get_anime_metadata(anime_url) -> dict` — Returns `{synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status}` -- `get_download_link(url) -> tuple[str, str]` — Returns `(video_player_url, filename)` +**Interface contract** — each site implements from `BaseAnimeSite`: +- `can_handle(url)` — URL pattern matching +- `search_anime(query, lang)` → `[{title, url, cover_image}]` +- `get_episodes(anime_url, lang)` → `[{episode_number, url, title, host}]` +- `get_anime_metadata(anime_url)` → `{synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status}` +- `get_download_link(url)` → `(video_player_url, filename)` -### Key Patterns -- **Pipe-separated URLs**: `video_url|anime_page_url|episode_title` — preserves context across extraction -- **Language parameter**: `lang="vostfr"` or `"vf"` — controls which episodes to return -- **Video player delegation**: Anime sites return player URLs (vidmoly, sendvid, sibnet, lpayer), not direct downloads -- **Filename generation**: `{anime_name} - S{season} - {episode}.mp4` format -- **HTTP headers**: Browser UA and referer required to avoid blocking +**Key patterns**: +- Pipe-separated URLs: `video_url|anime_page_url|episode_title` +- Language param: `lang="vostfr"` or `"vf"` +- Video player delegation: returns player URLs (vidmoly, sendvid, etc.), NOT direct downloads +- Filename format: `{anime_name} - S{season} - {episode}.mp4` +- Browser UA + referer headers required -### Domain Detection -- `AnimeSamaDownloader` fetches current domain from `anime-sama.pw` dynamically -- Uses fallback chain for video extraction: detected player → cached player → priority list +**Domain detection**: `AnimeSamaDownloader` fetches current domain from `anime-sama.pw` dynamically. Uses fallback chain for video extraction. -### Error Handling -- Raise `Exception` with descriptive message on failure -- Log at appropriate level (`debug` for expected failures, `error` for unexpected) -- Validate extracted URLs with `_test_video_url()` before returning +**Error handling**: Raise `Exception` with descriptive message. Log at `debug` for expected failures, `error` for unexpected. Validate URLs with `_test_video_url()` before returning. + +## ANTI-PATTERNS + +- Do NOT return direct download URLs from anime sites — return player URLs +- Do NOT skip URL validation — use `_test_video_url()` +- 5 empty `except:` blocks in `animesama.py` — known tech debt, silently swallow failures diff --git a/app/downloaders/anime_sites/animesama.py b/app/downloaders/anime_sites/animesama.py index f2c9175..a09da34 100644 --- a/app/downloaders/anime_sites/animesama.py +++ b/app/downloaders/anime_sites/animesama.py @@ -11,6 +11,7 @@ from urllib.parse import urljoin, unquote import binascii from Crypto.Cipher import AES from Crypto.Util.Padding import unpad + logger = logging.getLogger(__name__) # Lpayer encryption key (from Anime-Sama-Downloader project) @@ -24,7 +25,7 @@ def _decrypt_lpayer(hex_str: str) -> Optional[str]: data = binascii.unhexlify(hex_str) cipher = AES.new(LPAYER_KEY, AES.MODE_CBC, LPAYER_IV) decrypted = unpad(cipher.decrypt(data), AES.block_size) - return decrypted.decode('utf-8') + return decrypted.decode("utf-8") except Exception: return None @@ -33,11 +34,22 @@ class AnimeSamaDownloader(BaseAnimeSite): """Downloader for anime-sama.org / anime-sama.store""" # Static list of known domains (will be updated dynamically) - BASE_DOMAINS = ["anime-sama.to", "www.anime-sama.to", "anime-sama.tv", "www.anime-sama.tv", "anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"] + BASE_DOMAINS = [ + "anime-sama.to", + "www.anime-sama.to", + "anime-sama.tv", + "www.anime-sama.tv", + "anime-sama.si", + "www.anime-sama.si", + "anime-sama.org", + "anime-sama.store", + "anime-sama.eu", + ] def __init__(self): """Initialize AnimeSamaDownloader with working player cache""" super().__init__() # Call parent __init__ to initialize client + self.id = "anime-sama" self._working_players = {} # Cache: anime_url -> working player name @classmethod @@ -48,9 +60,15 @@ class AnimeSamaDownloader(BaseAnimeSite): """ try: import httpx + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: # Test known domains in order of recency - for test_domain in ["anime-sama.to", "anime-sama.tv", "anime-sama.si", "anime-sama.org"]: + for test_domain in [ + "anime-sama.to", + "anime-sama.tv", + "anime-sama.si", + "anime-sama.org", + ]: try: test_url = f"https://{test_domain}/catalogue" response = await client.get(test_url) @@ -58,7 +76,10 @@ class AnimeSamaDownloader(BaseAnimeSite): # Check if we got a valid page (not 404 and has content) if response.status_code == 200 and len(response.text) > 1000: # Check if it's the real anime-sama site (has catalog cards) - if 'catalogue' in response.text or 'catalog-card' in response.text: + if ( + "catalogue" in response.text + or "catalog-card" in response.text + ): logger.info(f"Working domain found: {test_domain}") return test_domain except Exception as e: @@ -83,8 +104,8 @@ class AnimeSamaDownloader(BaseAnimeSite): # Add the current domain and its www variant if not already present domains_to_add = [current_domain] - if not current_domain.startswith('www.'): - domains_to_add.append(f'www.{current_domain}') + if not current_domain.startswith("www."): + domains_to_add.append(f"www.{current_domain}") for domain in domains_to_add: if domain not in cls.BASE_DOMAINS: @@ -98,7 +119,9 @@ class AnimeSamaDownloader(BaseAnimeSite): def can_handle(self, url: str) -> bool: return any(domain in url.lower() for domain in self.BASE_DOMAINS) - async def get_download_link(self, url: str, target_filename: Optional[str] = None) -> tuple[str, str]: + async def get_download_link( + self, url: str, target_filename: Optional[str] = None + ) -> tuple[str, str]: """ Extract download link from anime-sama URL Anime-Sama uses third-party video hosts (vidmoly, etc.) @@ -109,98 +132,115 @@ class AnimeSamaDownloader(BaseAnimeSite): # Check if URL is a direct video URL (.mp4, .m3u8, .mkv) # If so, return it directly without extraction - if url.endswith('.mp4') or url.endswith('.m3u8') or url.endswith('.mkv'): + if url.endswith(".mp4") or url.endswith(".m3u8") or url.endswith(".mkv"): # Extract filename from URL from urllib.parse import urlparse, unquote + parsed = urlparse(url) path = unquote(parsed.path) - filename = path.split('/')[-1] if path.split('/')[-1] else "direct_video.mp4" + filename = ( + path.split("/")[-1] if path.split("/")[-1] else "direct_video.mp4" + ) logger.info(f"Direct video URL detected: {url[:60]}... -> {filename}") return url, filename - # Check if URL contains the anime page context (format: video_url|anime_page_url|episode_title?) - if '|' in url: - parts = url.split('|') + if "|" in url: + parts = url.split("|") video_url = parts[0] anime_page_url = parts[1] if len(parts) > 1 else None episode_title = parts[2] if len(parts) > 2 else None - logger.debug(f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}") + logger.debug( + f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}" + ) # Use fallback method for pipe-separated URLs (tries multiple players) return await self.get_download_link_with_fallback( video_url, anime_page_url=anime_page_url, - episode_title=episode_title + episode_title=episode_title, ) # Check if this is a third-party host URL - if 'vidmoly.to' in url or 'vidmoly.biz' in url or 'vidmoly' in url: + if "vidmoly.to" in url or "vidmoly.biz" in url or "vidmoly" in url: return await self._extract_from_vidmoly(url) # Handle direct Lpayer URLs (not embedded in anime-sama pages) - elif 'lpayer.' in url and url.startswith('https://lpayer.embed4me.com/'): + elif "lpayer." in url and url.startswith("https://lpayer.embed4me.com/"): # Direct video URL - return with fixed filename logger.info(f"Using direct Lpayer URL: {url[:80]}...") return url, "lpayer_video.mp4" # Handle Lpayer embedded pages (non-direct URLs) - elif 'lpayer.' in url: + elif "lpayer." in url: # Embedded page - use fallback logger.info(f"Using fallback for Lpayer embedded page: {url[:80]}...") return await self.get_download_link_with_fallback( - url, - anime_page_url=url, - episode_title=None + url, anime_page_url=url, episode_title=None ) # Handle Smoothpre URLs - elif 'smoothpre' in url.lower(): + elif "smoothpre" in url.lower(): logger.info(f"Using fallback for Smoothpre: {url[:80]}...") return await self.get_download_link_with_fallback( - url, - anime_page_url=None, - episode_title=None + url, anime_page_url=None, episode_title=None ) # If it's an anime-sama page, try to find the video - if 'anime-sama' in url.lower(): - if 'dingtez' in url or 'dingz' in url: + if "anime-sama" in url.lower(): + if "dingtez" in url or "dingz" in url: return await self._extract_from_dingetz(url) - elif 'wupstream' in url or 'wup' in url: + elif "wupstream" in url or "wup" in url: return await self._extract_from_wupstream(url) - elif 'doodstream' in url or 'dood' in url: + elif "doodstream" in url or "dood" in url: return await self._extract_from_doodstream(url) - elif 'streamtape' in url: + elif "streamtape" in url: return await self._extract_from_streamtape(url) - elif 'voe' in url: + elif "voe" in url: return await self._extract_from_voe(url) logger.debug(f"Processing anime-sama page: {url}") response = await self.client.get(url, follow_redirects=True) final_url = str(response.url) - soup = BeautifulSoup(response.text, 'lxml') + soup = BeautifulSoup(response.text, "lxml") logger.debug(f"Final URL after redirects: {final_url}") # Look for iframe with video player - iframes = soup.find_all('iframe') + iframes = soup.find_all("iframe") logger.debug(f"Found {len(iframes)} iframes") for iframe in iframes: - src = iframe.get('src', '') - if src and any(provider in src for provider in ['vidmoly', 'player', 'stream', 'play', 'embed', 'smoothpre']): - if not src.startswith('http'): + src = iframe.get("src", "") + if src and any( + provider in src + for provider in [ + "vidmoly", + "player", + "stream", + "play", + "embed", + "smoothpre", + ] + ): + if not src.startswith("http"): src = urljoin(final_url, src) logger.debug(f"Found iframe: {src}") # Try to extract video from the player try: # For vidmoly, extract and return the video URL directly - if 'vidmoly' in src: + if "vidmoly" in src: logger.debug(f"Extracting from vidmoly iframe: {src}") - video_url, filename = await self._extract_from_vidmoly(src, anime_page_url=url, episode_title="Episode") + video_url, filename = await self._extract_from_vidmoly( + src, anime_page_url=url, episode_title="Episode" + ) return video_url, filename # For smoothpre, use the smoothpre extractor - elif 'smoothpre' in src.lower(): + elif "smoothpre" in src.lower(): logger.debug(f"Extracting from smoothpre iframe: {src}") - video_url, filename = await self._extract_from_smoothpre(src, anime_page_url=url, episode_title="Episode") + ( + video_url, + filename, + ) = await self._extract_from_smoothpre( + src, anime_page_url=url, episode_title="Episode" + ) return video_url, filename else: video_url = await self._extract_from_player(src) @@ -212,35 +252,39 @@ class AnimeSamaDownloader(BaseAnimeSite): continue # Look for video tags - videos = soup.find_all('video') + videos = soup.find_all("video") logger.debug(f"Found {len(videos)} video tags") for video in videos: - src = video.get('src', '') + src = video.get("src", "") if src: - if not src.startswith('http'): + if not src.startswith("http"): src = urljoin(final_url, src) filename = self._generate_filename(final_url) return src, filename - sources = video.find_all('source') + sources = video.find_all("source") for source in sources: - src = source.get('src', '') + src = source.get("src", "") if src: - if not src.startswith('http'): + if not src.startswith("http"): src = urljoin(final_url, src) filename = self._generate_filename(final_url) return src, filename # If we couldn't find video in iframe, the page structure might have changed # Save HTML for debugging - logger.debug(f"Could not find video link on page. HTML snippet:\n{soup.prettify()[:1000]}") + logger.debug( + f"Could not find video link on page. HTML snippet:\n{soup.prettify()[:1000]}" + ) raise Exception("Could not find video link on page") except Exception as e: raise Exception(f"Error extracting AnimeSama link: {str(e)}") - async def _extract_from_vidmoly(self, url: str, anime_page_url: str = None, episode_title: str = None) -> tuple[str, str]: + async def _extract_from_vidmoly( + self, url: str, anime_page_url: str = None, episode_title: str = None + ) -> tuple[str, str]: """Extract video URL from vidmoly player - delegate to VidMolyDownloader""" try: logger.debug(f"Extracting from vidmoly: {url}") @@ -254,13 +298,19 @@ class AnimeSamaDownloader(BaseAnimeSite): anime_name = self._generate_anime_name(anime_page_url) season_num = self._extract_season_number(anime_page_url) if season_num: - target_filename = f"{anime_name} - S{season_num} - {episode_title}.mp4" + target_filename = ( + f"{anime_name} - S{season_num} - {episode_title}.mp4" + ) else: target_filename = f"{anime_name} - {episode_title}.mp4" - logger.debug(f"Generated filename: {target_filename} (episode: {episode_title})") + logger.debug( + f"Generated filename: {target_filename} (episode: {episode_title})" + ) elif anime_page_url: target_filename = self._generate_filename_from_anime_url(anime_page_url) - logger.debug(f"Generated filename: {target_filename} (no episode title)") + logger.debug( + f"Generated filename: {target_filename} (no episode title)" + ) else: target_filename = None logger.debug(f"No target_filename generated") @@ -270,9 +320,13 @@ class AnimeSamaDownloader(BaseAnimeSite): # Pass the target filename to VidMolyDownloader if available if target_filename: - video_url, temp_filename = await vidmoly_downloader.get_download_link(url, target_filename=target_filename) + video_url, temp_filename = await vidmoly_downloader.get_download_link( + url, target_filename=target_filename + ) else: - video_url, temp_filename = await vidmoly_downloader.get_download_link(url) + video_url, temp_filename = await vidmoly_downloader.get_download_link( + url + ) # Use the target filename filename = target_filename if target_filename else temp_filename @@ -281,12 +335,17 @@ class AnimeSamaDownloader(BaseAnimeSite): # Rename the file if needed import os + if temp_filename != filename: # temp_filename might be a full path or just the name - temp_path = temp_filename if os.path.isabs(temp_filename) else os.path.join('downloads', temp_filename) + temp_path = ( + temp_filename + if os.path.isabs(temp_filename) + else os.path.join("downloads", temp_filename) + ) if os.path.exists(temp_path): - final_path = os.path.join('downloads', filename) + final_path = os.path.join("downloads", filename) if os.path.exists(final_path): os.remove(final_path) os.rename(temp_path, final_path) @@ -302,7 +361,9 @@ class AnimeSamaDownloader(BaseAnimeSite): logger.debug(f"Vidmoly extraction error: {e}") raise Exception(f"Error extracting from vidmoly: {str(e)}") - async def _extract_from_sendvid(self, url: str, anime_page_url: str = None, episode_title: str = None) -> tuple[str, str]: + async def _extract_from_sendvid( + self, url: str, anime_page_url: str = None, episode_title: str = None + ) -> tuple[str, str]: """Extract video URL from sendvid player - delegate to SendVidDownloader""" try: logger.debug(f"Extracting from sendvid: {url}") @@ -316,13 +377,19 @@ class AnimeSamaDownloader(BaseAnimeSite): anime_name = self._generate_anime_name(anime_page_url) season_num = self._extract_season_number(anime_page_url) if season_num: - target_filename = f"{anime_name} - S{season_num} - {episode_title}.mp4" + target_filename = ( + f"{anime_name} - S{season_num} - {episode_title}.mp4" + ) else: target_filename = f"{anime_name} - {episode_title}.mp4" - logger.debug(f"Generated filename: {target_filename} (episode: {episode_title})") + logger.debug( + f"Generated filename: {target_filename} (episode: {episode_title})" + ) elif anime_page_url: target_filename = self._generate_filename_from_anime_url(anime_page_url) - logger.debug(f"Generated filename: {target_filename} (no episode title)") + logger.debug( + f"Generated filename: {target_filename} (no episode title)" + ) else: target_filename = None logger.debug(f"No target_filename generated") @@ -332,7 +399,9 @@ class AnimeSamaDownloader(BaseAnimeSite): # Pass the target filename to SendVidDownloader if available if target_filename: - video_url, filename = await sendvid_downloader.get_download_link(url, target_filename=target_filename) + video_url, filename = await sendvid_downloader.get_download_link( + url, target_filename=target_filename + ) else: video_url, filename = await sendvid_downloader.get_download_link(url) @@ -349,7 +418,9 @@ class AnimeSamaDownloader(BaseAnimeSite): logger.debug(f"SendVid extraction error: {e}") raise Exception(f"Error extracting from sendvid: {str(e)}") - async def _extract_from_sibnet(self, url: str, anime_page_url: str = None, episode_title: str = None) -> tuple[str, str]: + async def _extract_from_sibnet( + self, url: str, anime_page_url: str = None, episode_title: str = None + ) -> tuple[str, str]: """Extract video URL from sibnet player - delegate to SibnetDownloader""" try: logger.debug(f"Extracting from sibnet: {url}") @@ -363,13 +434,19 @@ class AnimeSamaDownloader(BaseAnimeSite): anime_name = self._generate_anime_name(anime_page_url) season_num = self._extract_season_number(anime_page_url) if season_num: - target_filename = f"{anime_name} - S{season_num} - {episode_title}.mp4" + target_filename = ( + f"{anime_name} - S{season_num} - {episode_title}.mp4" + ) else: target_filename = f"{anime_name} - {episode_title}.mp4" - logger.debug(f"Generated filename: {target_filename} (episode: {episode_title})") + logger.debug( + f"Generated filename: {target_filename} (episode: {episode_title})" + ) elif anime_page_url: target_filename = self._generate_filename_from_anime_url(anime_page_url) - logger.debug(f"Generated filename: {target_filename} (no episode title)") + logger.debug( + f"Generated filename: {target_filename} (no episode title)" + ) else: target_filename = None logger.debug(f"No target_filename generated") @@ -397,19 +474,21 @@ class AnimeSamaDownloader(BaseAnimeSite): try: # Extract anime name and season from URL like: https://anime-sama.si/catalogue/naruto/saison1/vostfr/ # Format: /catalogue/{anime}/saison{N}/{lang}/ - parts = anime_url.split('/') + parts = anime_url.split("/") anime_name = "Anime" season_num = None for i, part in enumerate(parts): - if part == 'catalogue' and i + 1 < len(parts): - anime_name = parts[i + 1].replace('-', ' ').title() + if part == "catalogue" and i + 1 < len(parts): + anime_name = parts[i + 1].replace("-", " ").title() # Extract season number for part in parts: - if 'saison' in part.lower(): + if "saison" in part.lower(): try: - season_num = int(part.replace('saison', '').replace('Saison', '')) + season_num = int( + part.replace("saison", "").replace("Saison", "") + ) break except: pass @@ -426,10 +505,10 @@ class AnimeSamaDownloader(BaseAnimeSite): """Extract just the anime name from anime-sama URL""" try: # Extract anime name from URL like: https://anime-sama.si/catalogue/naruto/saison1/vostfr/ - parts = anime_url.split('/') + parts = anime_url.split("/") for i, part in enumerate(parts): - if part == 'catalogue' and i + 1 < len(parts): - return parts[i + 1].replace('-', ' ').title() + if part == "catalogue" and i + 1 < len(parts): + return parts[i + 1].replace("-", " ").title() # Fallback return "Anime" except: @@ -438,15 +517,17 @@ class AnimeSamaDownloader(BaseAnimeSite): def _extract_season_number(self, anime_url: str) -> int | None: """Extract season number from anime-sama URL""" try: - parts = anime_url.split('/') + parts = anime_url.split("/") for part in parts: - if 'saison' in part.lower(): - return int(part.replace('saison', '').replace('Saison', '')) + if "saison" in part.lower(): + return int(part.replace("saison", "").replace("Saison", "")) return None except: return None - async def _extract_from_lpayer(self, url: str, anime_page_url: str = None, episode_title: str = None) -> tuple[str, str]: + async def _extract_from_lpayer( + self, url: str, anime_page_url: str = None, episode_title: str = None + ) -> tuple[str, str]: """Extract video URL from lpayer player - delegate to LpayerDownloader""" try: logger.debug(f"Extracting from lpayer: {url}") @@ -460,13 +541,19 @@ class AnimeSamaDownloader(BaseAnimeSite): anime_name = self._generate_anime_name(anime_page_url) season_num = self._extract_season_number(anime_page_url) if season_num: - target_filename = f"{anime_name} - S{season_num} - {episode_title}.mp4" + target_filename = ( + f"{anime_name} - S{season_num} - {episode_title}.mp4" + ) else: target_filename = f"{anime_name} - {episode_title}.mp4" - logger.debug(f"Generated filename: {target_filename} (episode: {episode_title})") + logger.debug( + f"Generated filename: {target_filename} (episode: {episode_title})" + ) elif anime_page_url: target_filename = self._generate_filename_from_anime_url(anime_page_url) - logger.debug(f"Generated filename: {target_filename} (no episode title)") + logger.debug( + f"Generated filename: {target_filename} (no episode title)" + ) else: target_filename = None logger.debug(f"No target_filename generated") @@ -488,83 +575,94 @@ class AnimeSamaDownloader(BaseAnimeSite): except Exception as e: logger.debug(f"Lpayer extraction error: {e}") # Re-raise with clearer message - raise Exception(f"Lpayer player not supported - this video host requires manual download. Try another host (VidMoly, SendVid, Sibnet). Error: {str(e)}") + raise Exception( + f"Lpayer player not supported - this video host requires manual download. Try another host (VidMoly, SendVid, Sibnet). Error: {str(e)}" + ) - async def _extract_from_lpayer_api(self, url: str, anime_page_url: str = None, episode_title: str = None, target_filename: str = None) -> tuple[str, str]: + async def _extract_from_lpayer_api( + self, + url: str, + anime_page_url: str = None, + episode_title: str = None, + target_filename: str = None, + ) -> tuple[str, str]: """Extract video URL from Lplayer using API decryption""" import requests - + # Extract video ID from URL - match = re.search(r'#([a-zA-Z0-9]+)', url) + match = re.search(r"#([a-zA-Z0-9]+)", url) if not match: - match = re.search(r'[?&]id=([a-zA-Z0-9]+)', url) + match = re.search(r"[?&]id=([a-zA-Z0-9]+)", url) if not match: raise Exception("Could not extract Lplayer video ID") - + video_id = match.group(1) api_url = f"https://lpayer.embed4me.com/api/v1/video?id={video_id}&w=1920&h=1080&r=https://lpayer.embed4me.com/" - + headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36", - "Referer": "https://lpayer.embed4me.com/" + "Referer": "https://lpayer.embed4me.com/", } - + response = requests.get(api_url, headers=headers, timeout=30) - + if response.status_code != 200: raise Exception(f"Lplayer API returned {response.status_code}") - + hex_data = response.text.strip() if hex_data.startswith('"') and hex_data.endswith('"'): hex_data = hex_data[1:-1] - + decrypted = _decrypt_lpayer(hex_data) if not decrypted: raise Exception("Failed to decrypt Lplayer response") - + data = json.loads(decrypted) - m3u8_url = data.get('source') - + m3u8_url = data.get("source") + if not m3u8_url: raise Exception("No source found in Lplayer response") - + # Use yt-dlp to get direct video URL from m3u8 cmd = [ - 'yt-dlp', - '--referer', 'https://lpayer.embed4me.com/', - '--skip-download', - '--dump-json', - '--no-warnings', - m3u8_url + "yt-dlp", + "--referer", + "https://lpayer.embed4me.com/", + "--skip-download", + "--dump-json", + "--no-warnings", + m3u8_url, ] - + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - + # Use target_filename if provided, otherwise fallback to default filename = target_filename if target_filename else f"lpayer_{video_id}.mp4" - + if result.returncode == 0 and result.stdout: yt_data = json.loads(result.stdout) - if 'formats' in yt_data: + if "formats" in yt_data: # Get best mp4 format (highest resolution) - formats = yt_data['formats'] - mp4_formats = [f for f in formats if f.get('ext') == 'mp4'] + formats = yt_data["formats"] + mp4_formats = [f for f in formats if f.get("ext") == "mp4"] if mp4_formats: # Sort by resolution (height) descending - mp4_formats.sort(key=lambda x: x.get('height', 0), reverse=True) - video_url = mp4_formats[0].get('url') + mp4_formats.sort(key=lambda x: x.get("height", 0), reverse=True) + video_url = mp4_formats[0].get("url") else: - video_url = formats[0].get('url') + video_url = formats[0].get("url") else: - video_url = yt_data.get('url') - + video_url = yt_data.get("url") + if video_url: return video_url, filename - + # If yt-dlp fails, return m3u8 URL anyway (let download manager handle it) return m3u8_url, filename - async def _extract_from_smoothpre(self, url: str, anime_page_url: str = None, episode_title: str = None) -> tuple[str, str]: + async def _extract_from_smoothpre( + self, url: str, anime_page_url: str = None, episode_title: str = None + ) -> tuple[str, str]: """Extract video URL from smoothpre player - delegate to SmoothpreDownloader""" try: logger.debug(f"Extracting from smoothpre: {url}") @@ -578,20 +676,28 @@ class AnimeSamaDownloader(BaseAnimeSite): anime_name = self._generate_anime_name(anime_page_url) season_num = self._extract_season_number(anime_page_url) if season_num: - target_filename = f"{anime_name} - S{season_num} - {episode_title}.mp4" + target_filename = ( + f"{anime_name} - S{season_num} - {episode_title}.mp4" + ) else: target_filename = f"{anime_name} - {episode_title}.mp4" - logger.debug(f"Generated filename: {target_filename} (episode: {episode_title})") + logger.debug( + f"Generated filename: {target_filename} (episode: {episode_title})" + ) elif anime_page_url: target_filename = self._generate_filename_from_anime_url(anime_page_url) - logger.debug(f"Generated filename: {target_filename} (no episode title)") + logger.debug( + f"Generated filename: {target_filename} (no episode title)" + ) else: target_filename = None logger.debug(f"No target_filename generated") # Use SmoothpreDownloader to extract the video URL smoothpre_downloader = SmoothpreDownloader() - video_url, temp_filename = await smoothpre_downloader.get_download_link(url, target_filename=target_filename) + video_url, temp_filename = await smoothpre_downloader.get_download_link( + url, target_filename=target_filename + ) # Use the target filename if available filename = target_filename if target_filename else temp_filename @@ -611,27 +717,30 @@ class AnimeSamaDownloader(BaseAnimeSite): """Try to extract direct video URL from player iframe""" try: response = await self.client.get(player_url) - soup = BeautifulSoup(response.text, 'lxml') + soup = BeautifulSoup(response.text, "lxml") # Check for video tags - videos = soup.find_all('video') + videos = soup.find_all("video") for video in videos: - src = video.get('src') or video.get('data-src') + src = video.get("src") or video.get("data-src") if src: return src # Check for source tags - sources = soup.find_all('source') + sources = soup.find_all("source") for source in sources: - src = source.get('src') - if src and any(ext in src for ext in ['mp4', 'm3u8', 'mkv']): + src = source.get("src") + if src and any(ext in src for ext in ["mp4", "m3u8", "mkv"]): return src # Check scripts in player page - scripts = soup.find_all('script') + scripts = soup.find_all("script") for script in scripts: if script.string: - match = re.search(r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)', script.string) + match = re.search( + r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)', + script.string, + ) if match: return match.group(1) @@ -644,17 +753,17 @@ class AnimeSamaDownloader(BaseAnimeSite): """Generate filename from URL""" # Extract anime name and episode info from URL # URL format: .../catalogue/{anime}/saison{N}/{vostfr|vf}/episode-{N} - parts = url.split('/') + parts = url.split("/") anime_name = "anime" episode = "1" for i, part in enumerate(parts): - if part == 'catalogue' and i + 1 < len(parts): - anime_name = parts[i + 1].replace('-', ' ') - elif 'episode-' in part: - episode = part.replace('episode-', '') - elif part in ['vostfr', 'vf']: + if part == "catalogue" and i + 1 < len(parts): + anime_name = parts[i + 1].replace("-", " ") + elif "episode-" in part: + episode = part.replace("episode-", "") + elif part in ["vostfr", "vf"]: lang = part.upper() filename = f"{anime_name} - Episode {episode}.mp4" @@ -668,31 +777,31 @@ class AnimeSamaDownloader(BaseAnimeSite): try: logger.debug(f"Extracting metadata from: {anime_url}") response = await self.client.get(anime_url) - soup = BeautifulSoup(response.text, 'lxml') + 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': [] + "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.synopsis", + "div.description", 'div[class*="synopsis"]', 'div[class*="description"]', - 'p.synopsis', - 'div.texte', - '.asn-synopsis' + "p.synopsis", + "div.texte", + ".asn-synopsis", ] for selector in synopsis_selectors: @@ -700,20 +809,22 @@ class AnimeSamaDownloader(BaseAnimeSite): if synopsis_elem: synopsis = synopsis_elem.get_text(strip=True) if len(synopsis) > 50: # Ensure it's actual content - metadata['synopsis'] = synopsis + metadata["synopsis"] = synopsis break # Extract genres # Look for genre tags/links genre_patterns = [ - r'Genre?\s*:?\s*([^\n]+)', - r'Type?\s*:?\s*([^\n]+)', + 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)) + 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]] + 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() @@ -722,23 +833,23 @@ class AnimeSamaDownloader(BaseAnimeSite): if match: genres_text = match.group(1) # Split by common separators - genres = [g.strip() for g in re.split(r'[,;/|]', genres_text)] + 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) + metadata["genres"].extend(genres) break # Remove duplicates - metadata['genres'] = list(set(metadata['genres'])) + metadata["genres"] = list(set(metadata["genres"])) # Extract rating rating_selectors = [ - 'span.rating', - 'div.rating', - 'span.score', + "span.rating", + "div.rating", + "span.score", 'div[class*="rating"]', 'div[class*="score"]', - '.asn-rating' + ".asn-rating", ] for selector in rating_selectors: @@ -746,41 +857,45 @@ class AnimeSamaDownloader(BaseAnimeSite): 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) + rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text) if rating_match: - metadata['rating'] = f"{rating_match.group(1)}/10" + metadata["rating"] = f"{rating_match.group(1)}/10" break - rating_match = re.search(r'(\d+\.?\d*)\s*/\s*5', rating_text) + 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" + 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})', + 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] + 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] + + 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,]+)', + r"Studio\s*:?\s*([^\n,]+)", + r"Produit\s*par\s*:?\s*([^\n,]+)", + r"Animation\s*:?\s*([^\n,]+)", ] for pattern in studio_patterns: @@ -788,39 +903,47 @@ class AnimeSamaDownloader(BaseAnimeSite): if match: studio = match.group(1).strip() if len(studio) > 2 and len(studio) < 100: - metadata['studio'] = studio + 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') + 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') + 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"]') + 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') + 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 + metadata["total_episodes"] = episodes_count # Extract status (ongoing/completed) status_patterns = [ - r'En\s*cours', - r'Ongoing', - r'Terminé', - r'Completed', - r'Finished', + 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' + if "cour" in pattern.lower() or "ongoing" in pattern.lower(): + metadata["status"] = "Ongoing" else: - metadata['status'] = 'Completed' + metadata["status"] = "Completed" break logger.debug(f"Extracted metadata: {metadata}") @@ -829,10 +952,13 @@ class AnimeSamaDownloader(BaseAnimeSite): except Exception as e: logger.debug(f"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]: + 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 @@ -849,6 +975,7 @@ class AnimeSamaDownloader(BaseAnimeSite): import time from html import unescape + start = time.time() logger.debug(f"Searching for '{query}' ({lang})...") @@ -862,8 +989,8 @@ class AnimeSamaDownloader(BaseAnimeSite): # Make POST request to search API response = await self.client.post( search_api_url, - data={'query': query}, - headers={'Content-Type': 'application/x-www-form-urlencoded'} + data={"query": query}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, ) elapsed = time.time() - start @@ -871,34 +998,34 @@ class AnimeSamaDownloader(BaseAnimeSite): if response.status_code == 200 and response.text.strip(): # Parse HTML results - soup = BeautifulSoup(response.text, 'lxml') + soup = BeautifulSoup(response.text, "lxml") results = [] # Extract all search result links - for link in soup.find_all('a', class_='asn-search-result'): - href = link.get('href', '') - title_elem = link.find('h3', class_='asn-search-result-title') - img_elem = link.find('img', class_='asn-search-result-img') + for link in soup.find_all("a", class_="asn-search-result"): + href = link.get("href", "") + title_elem = link.find("h3", class_="asn-search-result-title") + img_elem = link.find("img", class_="asn-search-result-img") title = unescape(title_elem.get_text()) if title_elem else "Unknown" - cover_image = img_elem.get('src', '') if img_elem else None + cover_image = img_elem.get("src", "") if img_elem else None # Add language parameter to URL - if '/saison1/' not in href: - href = href.rstrip('/') + f'/saison1/{lang}/' + if "/saison1/" not in href: + href = href.rstrip("/") + f"/saison1/{lang}/" result = { - 'title': title, - 'url': href, - 'cover_image': cover_image, - 'type': 'search_result', - 'metadata': None + "title": title, + "url": href, + "cover_image": cover_image, + "type": "search_result", + "metadata": None, } # Fetch metadata if requested if include_metadata: metadata = await self.get_anime_metadata(href) - result['metadata'] = metadata + result["metadata"] = metadata results.append(result) @@ -911,6 +1038,7 @@ class AnimeSamaDownloader(BaseAnimeSite): except Exception as e: logger.debug(f"Search error: {str(e)}") import traceback + traceback.print_exc() return [] @@ -922,67 +1050,79 @@ class AnimeSamaDownloader(BaseAnimeSite): """ try: logger.debug(f"Testing video URL: {url[:60]}...") - + # Build headers with appropriate referer based on URL headers = {"Range": "bytes=0-10240"} - + # Add referer for CDN URLs that require it (lpayer, etc.) - if '185.237.' in url or '203.188.' in url or 'lpayer' in url.lower() or '/mik/' in url: + if ( + "185.237." in url + or "203.188." in url + or "lpayer" in url.lower() + or "/mik/" in url + ): headers["Referer"] = "https://lpayer.embed4me.com/" - elif 'sibnet.ru' in url: + elif "sibnet.ru" in url: headers["Referer"] = "https://video.sibnet.ru/" - elif 'sendvid.com' in url: + elif "sendvid.com" in url: headers["Referer"] = "https://sendvid.com/" - elif 'vidmoly' in url: + elif "vidmoly" in url: headers["Referer"] = "https://vidmoly.to/" - + # Stream only first 10KB to validate the URL - response = await self.client.get( - url, - timeout=10.0, - headers=headers - ) - + response = await self.client.get(url, timeout=10.0, headers=headers) + if response.status_code in (200, 206): content_length = len(response.content) if content_length > 0: - logger.info(f"Video URL validation SUCCESS: {url[:60]}... ({content_length} bytes)") + logger.info( + f"Video URL validation SUCCESS: {url[:60]}... ({content_length} bytes)" + ) return True else: - logger.warning(f"Video URL validation FAILED: Empty response for {url[:60]}...") + logger.warning( + f"Video URL validation FAILED: Empty response for {url[:60]}..." + ) return False else: - logger.warning(f"Video URL validation FAILED: HTTP {response.status_code} for {url[:60]}...") + logger.warning( + f"Video URL validation FAILED: HTTP {response.status_code} for {url[:60]}..." + ) return False - + except httpx.TimeoutException: logger.warning(f"Video URL validation FAILED: Timeout for {url[:60]}...") return False except httpx.ConnectError as e: - logger.warning(f"Video URL validation FAILED: Connection error for {url[:60]}...: {e}") + logger.warning( + f"Video URL validation FAILED: Connection error for {url[:60]}...: {e}" + ) return False except Exception as e: logger.warning(f"Video URL validation FAILED: Error for {url[:60]}...: {e}") return False - async def _extract_with_ytdlp(self, url: str, provider: str = None) -> tuple[str, str]: + + async def _extract_with_ytdlp( + self, url: str, provider: str = None + ) -> tuple[str, str]: """ Extract video URL using yt-dlp with proper referer. This bypasses many blocking mechanisms. """ # Define referers for each provider referers = { - 'sendvid': 'https://sendvid.com/', - 'vidmoly': 'https://vidmoly.biz/', - 'sibnet': 'https://video.sibnet.ru/', - 'lpayer': 'https://lpayer.embed4me.com/', - 'dingtez': 'https://anime-sama.tv/', - 'streamtape': 'https://streamtape.com/', - 'voe': 'https://voe.sx/', - 'doodstream': 'https://doodstream.com/', + "sendvid": "https://sendvid.com/", + "vidmoly": "https://vidmoly.biz/", + "sibnet": "https://video.sibnet.ru/", + "lpayer": "https://lpayer.embed4me.com/", + "dingtez": "https://anime-sama.tv/", + "streamtape": "https://streamtape.com/", + "voe": "https://voe.sx/", + "doodstream": "https://doodstream.com/", } - + # Determine referer - referer = 'https://anime-sama.tv/' + referer = "https://anime-sama.tv/" if provider: referer = referers.get(provider.lower(), referer) else: @@ -990,83 +1130,80 @@ class AnimeSamaDownloader(BaseAnimeSite): if prov in url.lower(): referer = ref break - + try: cmd = [ - 'yt-dlp', - '--referer', referer, - '--skip-download', - '--dump-json', - '--no-warnings', - url + "yt-dlp", + "--referer", + referer, + "--skip-download", + "--dump-json", + "--no-warnings", + url, ] - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=30 - ) - + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0 and result.stdout: data = json.loads(result.stdout) - if 'formats' in data: - formats = data['formats'] - mp4_formats = [f for f in formats if f.get('ext') == 'mp4'] + if "formats" in data: + formats = data["formats"] + mp4_formats = [f for f in formats if f.get("ext") == "mp4"] if mp4_formats: - video_url = mp4_formats[0].get('url') + video_url = mp4_formats[0].get("url") else: - video_url = formats[0].get('url') + video_url = formats[0].get("url") else: - video_url = data.get('url') - + video_url = data.get("url") + if video_url: - return video_url, f"{provider}_video.mp4" if provider else "video.mp4" - + return ( + video_url, + f"{provider}_video.mp4" if provider else "video.mp4", + ) + raise Exception(f"yt-dlp failed: {result.stderr}") - + except subprocess.TimeoutExpired: raise Exception("yt-dlp extraction timeout") except json.JSONDecodeError: raise Exception("yt-dlp returned invalid JSON") - - async def get_download_link_with_fallback( self, url: str, target_filename: Optional[str] = None, anime_page_url: Optional[str] = None, - episode_title: Optional[str] = None + episode_title: Optional[str] = None, ) -> tuple[str, str]: """ Extract download link with fallback to multiple players and URLs. - + URL format: url1|url2|url3|anime_page_url|episode_title Player priority: detected from URL -> cached -> vidmoly -> sendvid -> sibnet -> lpayer Uses caching to remember working players per anime URL. Validates each URL with _test_video_url() before returning. - + Args: url: Video player URL or pipe-separated URLs target_filename: Optional target filename for the download anime_page_url: URL of the anime page (for caching key) episode_title: Episode title (for filename generation) - + Returns: Tuple of (video_url, filename) - + Raises: Exception: If all players fail """ # Define player priority list - player_priority = ['vidmoly', 'sendvid', 'sibnet', 'lpayer', 'smoothpre'] - + player_priority = ["vidmoly", "sendvid", "sibnet", "lpayer", "smoothpre"] + # Extract video URLs from pipe format if needed # Format: url1|url2|url3|anime_page_url|episode_title video_urls = [] - if '|' in url: - parts = url.split('|') + if "|" in url: + parts = url.split("|") # Last 2 parts are anime_page_url and episode_title (if present) # Everything before is video URLs if len(parts) >= 3: @@ -1078,7 +1215,7 @@ class AnimeSamaDownloader(BaseAnimeSite): episode_title = parts[-1] else: video_urls = [parts[0]] - if len(parts) > 1 and 'anime-sama' in parts[1]: + if len(parts) > 1 and "anime-sama" in parts[1]: anime_page_url = parts[1] else: video_urls = [url] @@ -1093,28 +1230,34 @@ class AnimeSamaDownloader(BaseAnimeSite): continue # Skip URLs with incomplete query parameters (e.g., "videoid=" without value) - if '=&' in vu or vu.endswith('='): - logger.warning(f"Skipping incomplete URL (missing parameter value): {vu[:80]}...") + if "=&" in vu or vu.endswith("="): + logger.warning( + f"Skipping incomplete URL (missing parameter value): {vu[:80]}..." + ) continue # Skip URLs that are just a base domain without ID (e.g., "https://sendvid.com/embed/") - if vu.endswith('/') and len(vu) > 10: + if vu.endswith("/") and len(vu) > 10: # Check if it's a base player URL without video ID base_urls = [ - 'https://sendvid.com/embed/', - 'https://sendvid.com/embed', - 'https://vidmoly.to/embed/', - 'https://vidmoly.to/embed', - 'https://vidmoly.biz/embed/', - 'https://vidmoly.biz/embed', + "https://sendvid.com/embed/", + "https://sendvid.com/embed", + "https://vidmoly.to/embed/", + "https://vidmoly.to/embed", + "https://vidmoly.biz/embed/", + "https://vidmoly.biz/embed", ] if any(vu.startswith(base) for base in base_urls): - logger.warning(f"Skipping incomplete URL (no video ID): {vu[:60]}...") + logger.warning( + f"Skipping incomplete URL (no video ID): {vu[:60]}..." + ) continue # Skip URLs with incomplete HTML filenames (e.g., "embed-.html") - if 'embed-.html' in vu or 'embed_' in vu: - logger.warning(f"Skipping malformed URL (incomplete HTML): {vu[:80]}...") + if "embed-.html" in vu or "embed_" in vu: + logger.warning( + f"Skipping malformed URL (incomplete HTML): {vu[:80]}..." + ) continue valid_video_urls.append(vu) @@ -1128,39 +1271,45 @@ class AnimeSamaDownloader(BaseAnimeSite): last_error = None for video_url in video_urls: logger.info(f"Trying video URL: {video_url[:50]}...") - + # Detect player type from URL detected_player = None url_lower = video_url.lower() - if 'vidmoly' in url_lower: - detected_player = 'vidmoly' - elif 'sendvid' in url_lower: - detected_player = 'sendvid' - elif 'sibnet' in url_lower: - detected_player = 'sibnet' - elif 'lpayer' in url_lower or 'embed' in url_lower: - detected_player = 'lpayer' - elif 'dingtez' in url_lower: - detected_player = 'lpayer' # Unknown player, try lpayer as fallback - + if "vidmoly" in url_lower: + detected_player = "vidmoly" + elif "sendvid" in url_lower: + detected_player = "sendvid" + elif "sibnet" in url_lower: + detected_player = "sibnet" + elif "lpayer" in url_lower or "embed" in url_lower: + detected_player = "lpayer" + elif "dingtez" in url_lower: + detected_player = "lpayer" # Unknown player, try lpayer as fallback + logger.debug(f"Detected player from URL: {detected_player}") - + # Determine which player to try first cached_player = None if anime_page_url and anime_page_url in self._working_players: cached_player = self._working_players[anime_page_url] - logger.info(f"Using cached player '{cached_player}' for anime: {anime_page_url[:50]}...") - + logger.info( + f"Using cached player '{cached_player}' for anime: {anime_page_url[:50]}..." + ) + # Build player order: cached player first, then detected, then rest in priority order player_order = [] if cached_player and cached_player in player_priority: player_order.append(cached_player) - if detected_player and detected_player not in player_order and detected_player in player_priority: + if ( + detected_player + and detected_player not in player_order + and detected_player in player_priority + ): player_order.append(detected_player) for p in player_priority: if p not in player_order: player_order.append(p) - + # Only iterate through all players if there are MULTIPLE video URLs # Otherwise, just use the detected player (or first in priority) if len(video_urls) == 1: @@ -1169,27 +1318,32 @@ class AnimeSamaDownloader(BaseAnimeSite): player_order = [detected_player] else: player_order = [player_priority[0]] # Just try first one - + # Try each player for this video URL for player_name in player_order: try: logger.info(f"Trying player: {player_name} for {video_url[:50]}...") - - if player_name == 'vidmoly': + + if player_name == "vidmoly": video_url_result, filename = await self._extract_from_vidmoly( video_url, anime_page_url, episode_title ) - elif player_name == 'sendvid': + elif player_name == "sendvid": video_url_result, filename = await self._extract_from_sendvid( video_url, anime_page_url, episode_title ) - elif player_name == 'sibnet': + elif player_name == "sibnet": video_url_result, filename = await self._extract_from_sibnet( video_url, anime_page_url, episode_title ) - elif player_name == 'lpayer': - video_url_result, filename = await self._extract_from_lpayer_api(video_url, anime_page_url, episode_title, target_filename) - elif player_name == 'smoothpre': + elif player_name == "lpayer": + ( + video_url_result, + filename, + ) = await self._extract_from_lpayer_api( + video_url, anime_page_url, episode_title, target_filename + ) + elif player_name == "smoothpre": video_url_result, filename = await self._extract_from_smoothpre( video_url, anime_page_url, episode_title ) @@ -1197,29 +1351,33 @@ class AnimeSamaDownloader(BaseAnimeSite): # Validate the extracted URL logger.info(f"Validating extracted URL from {player_name}...") is_valid = await self._test_video_url(video_url_result) - + if is_valid: logger.info(f"SUCCESS: {player_name} returned valid video URL") # Cache this working player for future requests if anime_page_url: self._working_players[anime_page_url] = player_name - logger.debug(f"Cached working player '{player_name}' for anime URL") - + logger.debug( + f"Cached working player '{player_name}' for anime URL" + ) + # Use target_filename if provided if target_filename: filename = target_filename - + return video_url_result, filename else: - logger.warning(f"FAILED: {player_name} returned invalid video URL (validation failed)") + logger.warning( + f"FAILED: {player_name} returned invalid video URL (validation failed)" + ) last_error = f"{player_name} returned invalid URL" continue - + except Exception as e: logger.warning(f"FAILED: {player_name} extraction failed: {str(e)}") last_error = str(e) continue - + # All players failed error_msg = f"All players failed. Last error: {last_error}" logger.error(error_msg) @@ -1233,16 +1391,18 @@ class AnimeSamaDownloader(BaseAnimeSite): """ try: response = await self.client.get(anime_url) - soup = BeautifulSoup(response.text, 'lxml') + soup = BeautifulSoup(response.text, "lxml") episodes = [] # Try to find the episodes.js file in the HTML - episodes_js_match = re.search(r'episodes\.js\?filever=(\d+)', response.text) + episodes_js_match = re.search(r"episodes\.js\?filever=(\d+)", response.text) if episodes_js_match: file_ver = episodes_js_match.group(1) # Build the URL to episodes.js - episodes_js_url = f"{anime_url.rstrip('/')}/episodes.js?filever={file_ver}" + episodes_js_url = ( + f"{anime_url.rstrip('/')}/episodes.js?filever={file_ver}" + ) logger.debug(f"Found episodes.js at {episodes_js_url}") @@ -1255,45 +1415,48 @@ class AnimeSamaDownloader(BaseAnimeSite): # Format A (Season 1 style): var eps1 = [ep1_url1, ep1_url2, ..., ep28_url1] - One array per SOURCE # Format B (Season 2 style): var eps1 = [ep1_url1, ep1_url2], var eps2 = [ep2_url1, ep2_url2] - One array per EPISODE - eps_matches = re.findall(r'var\s+eps(\d+)\s*=\s*(\[[^\]]+\])', js_content) + eps_matches = re.findall( + r"var\s+eps(\d+)\s*=\s*(\[[^\]]+\])", js_content + ) if eps_matches: # Determine the format by looking at the data # Format A: each epsX array is one SOURCE with all episodes (different domains per array) # Format B: each epsX array is one EPISODE with multiple sources (same domains across arrays) - + eps1_urls = re.findall(r"'(https?://[^']+)'", eps_matches[0][1]) num_episode_arrays = len(eps_matches) - + is_format_a = True # Default - + if num_episode_arrays >= 2: # Extract domains from first URLs of each array def get_domain(url): - return url.split('/')[2] if '/' in url else url - + return url.split("/")[2] if "/" in url else url + domains_per_array = [] for eps_num, urls_text in eps_matches: urls = re.findall(r"'(https?://[^']+)'", urls_text) if urls: - domains = set(get_domain(u) for u in urls[:3]) # Sample first 3 + domains = set( + get_domain(u) for u in urls[:3] + ) # Sample first 3 domains_per_array.append(domains) - + # Check if domains are different across arrays # If each array has completely different domains → Format A (each = source) # If arrays share domains → Format B (each = episode with multiple sources) all_domains = set() for domains in domains_per_array: all_domains.update(domains) - + # If total unique domains ≈ sum of domains per array → Format A # If total unique domains << sum of domains per array → Format B (shared) total_domain_count = sum(len(d) for d in domains_per_array) if len(all_domains) < total_domain_count * 0.7: # Domains are shared across arrays → Format B is_format_a = False - - + # No more host preference! # No more host preference! Just collect all available URLs for each episode @@ -1303,7 +1466,9 @@ class AnimeSamaDownloader(BaseAnimeSite): if is_format_a: # Format A: Each epsX is a different source, containing all episodes for eps_num, urls_text in eps_matches: - episode_urls = re.findall(r"'(https?://[^']+)'", urls_text) + episode_urls = re.findall( + r"'(https?://[^']+)'", urls_text + ) for idx, url in enumerate(episode_urls, start=1): episode_num = str(idx).zfill(2) @@ -1316,7 +1481,9 @@ class AnimeSamaDownloader(BaseAnimeSite): # Format B: Each epsX is an episode, containing multiple sources for eps_num, urls_text in eps_matches: episode_num = str(eps_num).zfill(2) - episode_urls = re.findall(r"'(https?://[^']+)'", urls_text) + episode_urls = re.findall( + r"'(https?://[^']+)'", urls_text + ) if episode_num not in all_episodes_by_number: all_episodes_by_number[episode_num] = [] @@ -1330,15 +1497,21 @@ class AnimeSamaDownloader(BaseAnimeSite): # Use ALL available URLs (pipe-separated) for fallback # Format: url1|url2|url3|anime_page_url|episode_title episode_urls_separator = "|".join(available_urls) - episode_title = f'Episode {episode_num}' - combined_url = f"{episode_urls_separator}|{anime_url}|{episode_title}" + episode_title = f"Episode {episode_num}" + combined_url = ( + f"{episode_urls_separator}|{anime_url}|{episode_title}" + ) - episodes.append({ - 'episode': episode_num, - 'url': combined_url, - 'title': episode_title, - 'available_hosts': len(available_urls) # Store count of available hosts - }) + episodes.append( + { + "episode": episode_num, + "url": combined_url, + "title": episode_title, + "available_hosts": len( + available_urls + ), # Store count of available hosts + } + ) logger.debug(f"Found {len(episodes)} episodes") return episodes @@ -1346,13 +1519,14 @@ class AnimeSamaDownloader(BaseAnimeSite): except Exception as e: logger.debug(f"Error fetching episodes.js: {e}") import traceback + traceback.print_exc() # Fallback: Try to find episode links in the HTML (old method) logger.debug(f"Using fallback method to find episodes in HTML") # Quick check: look for episode links with limited scope - episode_links = soup.find_all('a', href=lambda x: x and 'episode-' in x) + episode_links = soup.find_all("a", href=lambda x: x and "episode-" in x) logger.debug(f"Found {len(episode_links)} episode links") if not episode_links: @@ -1361,29 +1535,28 @@ class AnimeSamaDownloader(BaseAnimeSite): return [] for link in episode_links: - href = link['href'] - if 'episode-' in href: + href = link["href"] + if "episode-" in href: # Extract episode number - match = re.search(r'episode-(\d+)', href) + match = re.search(r"episode-(\d+)", href) if match: episode_num = match.group(1) full_url = urljoin(anime_url, href) - logger.debug(f"Fallback: Found episode {episode_num} at {full_url}") + logger.debug( + f"Fallback: Found episode {episode_num} at {full_url}" + ) - episodes.append({ - 'episode': episode_num, - 'url': full_url - }) + episodes.append({"episode": episode_num, "url": full_url}) # Remove duplicates and sort seen = set() unique_episodes = [] for ep in episodes: - if ep['episode'] not in seen: - seen.add(ep['episode']) + if ep["episode"] not in seen: + seen.add(ep["episode"]) unique_episodes.append(ep) - unique_episodes.sort(key=lambda x: int(x['episode'])) + unique_episodes.sort(key=lambda x: int(x["episode"])) return unique_episodes @@ -1444,7 +1617,7 @@ class AnimeSamaDownloader(BaseAnimeSite): try: response = await self.client.get(anime_url) - soup = BeautifulSoup(response.text, 'lxml') + soup = BeautifulSoup(response.text, "lxml") seasons = [] @@ -1452,10 +1625,10 @@ class AnimeSamaDownloader(BaseAnimeSite): # Anime-Sama typically has season links in a navigation or menu season_selectors = [ 'a[href*="/saison"]', - 'a.season-link', - 'div.seasons a', - 'ul.season-list a', - 'nav a[href*="saison"]' + "a.season-link", + "div.seasons a", + "ul.season-list a", + 'nav a[href*="saison"]', ] season_links = [] @@ -1467,15 +1640,16 @@ class AnimeSamaDownloader(BaseAnimeSite): # Extract base URL and anime name from urllib.parse import urlparse + parsed = urlparse(anime_url) base_url = f"{parsed.scheme}://{parsed.netloc}" # Extract anime name from URL # URL format: https://anime-sama.si/catalogue/{anime}/saison1/{lang}/ - url_parts = anime_url.split('/') + url_parts = anime_url.split("/") anime_name = None for i, part in enumerate(url_parts): - if part == 'catalogue' and i + 1 < len(url_parts): + if part == "catalogue" and i + 1 < len(url_parts): anime_name = url_parts[i + 1] break @@ -1486,26 +1660,35 @@ class AnimeSamaDownloader(BaseAnimeSite): if not season_links: # Quick check function for a single season async def check_season(season_num): - season_url = f"{base_url}/catalogue/{anime_name}/saison{season_num}/vostfr/" + season_url = ( + f"{base_url}/catalogue/{anime_name}/saison{season_num}/vostfr/" + ) try: # Quick check with short timeout test_response = await self.client.get(season_url, timeout=3.0) - if test_response.status_code == 200 and 'episodes.js' in test_response.text: + if ( + test_response.status_code == 200 + and "episodes.js" in test_response.text + ): # Season exists, return info return { - 'season': season_num, - 'title': f'Saison {season_num}', - 'url': season_url, - 'episode_count': None # Will fetch later if needed + "season": season_num, + "title": f"Saison {season_num}", + "url": season_url, + "episode_count": None, # Will fetch later if needed } except httpx.TimeoutException: # Silent skip - season likely doesn't exist pass except httpx.ConnectError as e: - logger.debug(f"Connection error checking season {season_num}: {e}") + logger.debug( + f"Connection error checking season {season_num}: {e}" + ) except Exception as e: - logger.debug(f"Unexpected error checking season {season_num}: {e}") + logger.debug( + f"Unexpected error checking season {season_num}: {e}" + ) return None # Check seasons 1-10 in parallel @@ -1520,42 +1703,52 @@ class AnimeSamaDownloader(BaseAnimeSite): # Now fetch episode counts in parallel for existing seasons only async def fetch_episode_count(season_info): try: - episodes = await self.get_episodes(season_info['url']) + episodes = await self.get_episodes(season_info["url"]) episode_count = len(episodes) if episodes else 0 - logger.debug(f"Saison {season_info['season']} has {episode_count} episodes") + logger.debug( + f"Saison {season_info['season']} has {episode_count} episodes" + ) # Only return seasons that actually have episodes if episode_count > 0: - season_info['episode_count'] = episode_count + season_info["episode_count"] = episode_count return season_info else: # Skip seasons with no episodes - logger.debug(f"Skipping Saison {season_info['season']} (no episodes)") + logger.debug( + f"Skipping Saison {season_info['season']} (no episodes)" + ) return None except httpx.TimeoutException: - logger.debug(f"Timeout fetching episodes for season {season_info['season']}") + logger.debug( + f"Timeout fetching episodes for season {season_info['season']}" + ) except Exception as e: - logger.debug(f"Error fetching episodes for season {season_info['season']}: {e}") + logger.debug( + f"Error fetching episodes for season {season_info['season']}: {e}" + ) return None if seasons: episode_tasks = [fetch_episode_count(s) for s in seasons] - seasons_with_eps = await asyncio.gather(*episode_tasks, return_exceptions=True) + seasons_with_eps = await asyncio.gather( + *episode_tasks, return_exceptions=True + ) # Filter out seasons with no episodes or failed requests seasons = [s for s in seasons_with_eps if s and isinstance(s, dict)] else: # Parse the season links we found for link in season_links: - href = link.get('href', '') - if 'saison' in href: + href = link.get("href", "") + if "saison" in href: # Extract season number - season_match = re.search(r'saison(\d+)', href) + season_match = re.search(r"saison(\d+)", href) if season_match: season_num = int(season_match.group(1)) # Build full URL if needed - if href.startswith('http'): + if href.startswith("http"): season_url = href - elif href.startswith('/'): + elif href.startswith("/"): season_url = base_url + href else: season_url = urljoin(anime_url, href) @@ -1565,21 +1758,29 @@ class AnimeSamaDownloader(BaseAnimeSite): episodes = await self.get_episodes(season_url) episode_count = len(episodes) if episodes else 0 if episode_count > 0: - seasons.append({ - 'season': season_num, - 'title': f'Saison {season_num}', - 'url': season_url, - 'episode_count': episode_count - }) + seasons.append( + { + "season": season_num, + "title": f"Saison {season_num}", + "url": season_url, + "episode_count": episode_count, + } + ) else: - logger.debug(f"Skipping season {season_num} (no episodes)") + logger.debug( + f"Skipping season {season_num} (no episodes)" + ) except httpx.TimeoutException: - logger.debug(f"Timeout fetching episodes for season {season_num}") + logger.debug( + f"Timeout fetching episodes for season {season_num}" + ) except Exception as e: - logger.debug(f"Error fetching episodes for season {season_num}: {e}") + logger.debug( + f"Error fetching episodes for season {season_num}: {e}" + ) # Sort by season number - seasons.sort(key=lambda x: x['season']) + seasons.sort(key=lambda x: x["season"]) logger.debug(f"Found {len(seasons)} seasons for {anime_name}") return seasons @@ -1587,6 +1788,7 @@ class AnimeSamaDownloader(BaseAnimeSite): except Exception as e: logger.debug(f"Error getting seasons: {e}") import traceback + traceback.print_exc() return [] @@ -1598,44 +1800,53 @@ class AnimeSamaDownloader(BaseAnimeSite): """ try: logger.debug(f"Testing video URL: {url[:60]}...") - + # Build headers with appropriate referer based on URL headers = {"Range": "bytes=0-10240"} - + # Add referer for CDN URLs that require it (lpayer, etc.) - if '185.237.' in url or '203.188.' in url or 'lpayer' in url.lower() or '/mik/' in url: + if ( + "185.237." in url + or "203.188." in url + or "lpayer" in url.lower() + or "/mik/" in url + ): headers["Referer"] = "https://lpayer.embed4me.com/" - elif 'sibnet.ru' in url: + elif "sibnet.ru" in url: headers["Referer"] = "https://video.sibnet.ru/" - elif 'sendvid.com' in url: + elif "sendvid.com" in url: headers["Referer"] = "https://sendvid.com/" - elif 'vidmoly' in url: + elif "vidmoly" in url: headers["Referer"] = "https://vidmoly.to/" - + # Stream only first 10KB to validate the URL - response = await self.client.get( - url, - timeout=10.0, - headers=headers - ) - + response = await self.client.get(url, timeout=10.0, headers=headers) + if response.status_code in (200, 206): content_length = len(response.content) if content_length > 0: - logger.info(f"Video URL validation SUCCESS: {url[:60]}... ({content_length} bytes)") + logger.info( + f"Video URL validation SUCCESS: {url[:60]}... ({content_length} bytes)" + ) return True else: - logger.warning(f"Video URL validation FAILED: Empty response for {url[:60]}...") + logger.warning( + f"Video URL validation FAILED: Empty response for {url[:60]}..." + ) return False else: - logger.warning(f"Video URL validation FAILED: HTTP {response.status_code} for {url[:60]}...") + logger.warning( + f"Video URL validation FAILED: HTTP {response.status_code} for {url[:60]}..." + ) return False - + except httpx.TimeoutException: logger.warning(f"Video URL validation FAILED: Timeout for {url[:60]}...") return False except httpx.ConnectError as e: - logger.warning(f"Video URL validation FAILED: Connection error for {url[:60]}...: {e}") + logger.warning( + f"Video URL validation FAILED: Connection error for {url[:60]}...: {e}" + ) return False except Exception as e: logger.warning(f"Video URL validation FAILED: Error for {url[:60]}...: {e}") @@ -1646,36 +1857,36 @@ class AnimeSamaDownloader(BaseAnimeSite): url: str, target_filename: Optional[str] = None, anime_page_url: Optional[str] = None, - episode_title: Optional[str] = None + episode_title: Optional[str] = None, ) -> tuple[str, str]: """ Extract download link with fallback to multiple players and URLs. - + URL format: url1|url2|url3|anime_page_url|episode_title Player priority: detected from URL -> cached -> vidmoly -> sendvid -> sibnet -> lpayer Uses caching to remember working players per anime URL. Validates each URL with _test_video_url() before returning. - + Args: url: Video player URL or pipe-separated URLs target_filename: Optional target filename for the download anime_page_url: URL of the anime page (for caching key) episode_title: Episode title (for filename generation) - + Returns: Tuple of (video_url, filename) - + Raises: Exception: If all players fail """ # Define player priority list - player_priority = ['vidmoly', 'sendvid', 'sibnet', 'lpayer', 'smoothpre'] - + player_priority = ["vidmoly", "sendvid", "sibnet", "lpayer", "smoothpre"] + # Extract video URLs from pipe format if needed # Format: url1|url2|url3|anime_page_url|episode_title video_urls = [] - if '|' in url: - parts = url.split('|') + if "|" in url: + parts = url.split("|") # Last 2 parts are anime_page_url and episode_title (if present) # Everything before is video URLs if len(parts) >= 3: @@ -1687,7 +1898,7 @@ class AnimeSamaDownloader(BaseAnimeSite): episode_title = parts[-1] else: video_urls = [parts[0]] - if len(parts) > 1 and 'anime-sama' in parts[1]: + if len(parts) > 1 and "anime-sama" in parts[1]: anime_page_url = parts[1] else: video_urls = [url] @@ -1702,28 +1913,34 @@ class AnimeSamaDownloader(BaseAnimeSite): continue # Skip URLs with incomplete query parameters (e.g., "videoid=" without value) - if '=&' in vu or vu.endswith('='): - logger.warning(f"Skipping incomplete URL (missing parameter value): {vu[:80]}...") + if "=&" in vu or vu.endswith("="): + logger.warning( + f"Skipping incomplete URL (missing parameter value): {vu[:80]}..." + ) continue # Skip URLs that are just a base domain without ID (e.g., "https://sendvid.com/embed/") - if vu.endswith('/') and len(vu) > 10: + if vu.endswith("/") and len(vu) > 10: # Check if it's a base player URL without video ID base_urls = [ - 'https://sendvid.com/embed/', - 'https://sendvid.com/embed', - 'https://vidmoly.to/embed/', - 'https://vidmoly.to/embed', - 'https://vidmoly.biz/embed/', - 'https://vidmoly.biz/embed', + "https://sendvid.com/embed/", + "https://sendvid.com/embed", + "https://vidmoly.to/embed/", + "https://vidmoly.to/embed", + "https://vidmoly.biz/embed/", + "https://vidmoly.biz/embed", ] if any(vu.startswith(base) for base in base_urls): - logger.warning(f"Skipping incomplete URL (no video ID): {vu[:60]}...") + logger.warning( + f"Skipping incomplete URL (no video ID): {vu[:60]}..." + ) continue # Skip URLs with incomplete HTML filenames (e.g., "embed-.html") - if 'embed-.html' in vu or 'embed_' in vu: - logger.warning(f"Skipping malformed URL (incomplete HTML): {vu[:80]}...") + if "embed-.html" in vu or "embed_" in vu: + logger.warning( + f"Skipping malformed URL (incomplete HTML): {vu[:80]}..." + ) continue valid_video_urls.append(vu) @@ -1741,29 +1958,31 @@ class AnimeSamaDownloader(BaseAnimeSite): # Detect player type from URL detected_player = None url_lower = video_url.lower() - if 'vidmoly' in url_lower: - detected_player = 'vidmoly' - elif 'sendvid' in url_lower: - detected_player = 'sendvid' - elif 'sibnet' in url_lower: - detected_player = 'sibnet' - elif 'lpayer' in url_lower: - detected_player = 'lpayer' - elif 'smoothpre' in url_lower: - detected_player = 'smoothpre' - elif 'myvi' in url_lower or 'myvi.tv' in url_lower: - detected_player = 'vidmoly' # MyVi is similar to VidMoly, try VidMoly downloader first - elif 'dingtez' in url_lower: - detected_player = 'lpayer' # Unknown player, try lpayer as fallback + if "vidmoly" in url_lower: + detected_player = "vidmoly" + elif "sendvid" in url_lower: + detected_player = "sendvid" + elif "sibnet" in url_lower: + detected_player = "sibnet" + elif "lpayer" in url_lower: + detected_player = "lpayer" + elif "smoothpre" in url_lower: + detected_player = "smoothpre" + elif "myvi" in url_lower or "myvi.tv" in url_lower: + detected_player = "vidmoly" # MyVi is similar to VidMoly, try VidMoly downloader first + elif "dingtez" in url_lower: + detected_player = "lpayer" # Unknown player, try lpayer as fallback logger.debug(f"Detected player from URL: {detected_player}") - + # Determine which player to try first cached_player = None if anime_page_url and anime_page_url in self._working_players: cached_player = self._working_players[anime_page_url] - logger.info(f"Using cached player '{cached_player}' for anime: {anime_page_url[:50]}...") - + logger.info( + f"Using cached player '{cached_player}' for anime: {anime_page_url[:50]}..." + ) + # Build player order: cached player first, then detected, then rest in priority order player_order = [] @@ -1773,47 +1992,62 @@ class AnimeSamaDownloader(BaseAnimeSite): # Multiple URLs: only try the detected player (or first in priority if none detected) if detected_player and detected_player in player_priority: player_order = [detected_player] - logger.info(f"Multiple URLs detected, trying only detected player: {detected_player}") + logger.info( + f"Multiple URLs detected, trying only detected player: {detected_player}" + ) else: # No player detected, try cached if available, otherwise first in priority if cached_player and cached_player in player_priority: player_order = [cached_player] - logger.info(f"Multiple URLs with no detected player, trying cached: {cached_player}") + logger.info( + f"Multiple URLs with no detected player, trying cached: {cached_player}" + ) else: player_order = [player_priority[0]] - logger.info(f"Multiple URLs with no detected/cached player, trying: {player_order[0]}") + logger.info( + f"Multiple URLs with no detected/cached player, trying: {player_order[0]}" + ) else: # Single URL: try cached player first, then detected, then all others in priority if cached_player and cached_player in player_priority: player_order.append(cached_player) - if detected_player and detected_player not in player_order and detected_player in player_priority: + if ( + detected_player + and detected_player not in player_order + and detected_player in player_priority + ): player_order.append(detected_player) for p in player_priority: if p not in player_order: player_order.append(p) logger.info(f"Player order: {player_order}") - + # Try each player for this video URL for player_name in player_order: try: logger.info(f"Trying player: {player_name} for {video_url[:50]}...") - - if player_name == 'vidmoly': + + if player_name == "vidmoly": video_url_result, filename = await self._extract_from_vidmoly( video_url, anime_page_url, episode_title ) - elif player_name == 'sendvid': + elif player_name == "sendvid": video_url_result, filename = await self._extract_from_sendvid( video_url, anime_page_url, episode_title ) - elif player_name == 'sibnet': + elif player_name == "sibnet": video_url_result, filename = await self._extract_from_sibnet( video_url, anime_page_url, episode_title ) - elif player_name == 'lpayer': - video_url_result, filename = await self._extract_from_lpayer_api(video_url, anime_page_url, episode_title, target_filename) - elif player_name == 'smoothpre': + elif player_name == "lpayer": + ( + video_url_result, + filename, + ) = await self._extract_from_lpayer_api( + video_url, anime_page_url, episode_title, target_filename + ) + elif player_name == "smoothpre": video_url_result, filename = await self._extract_from_smoothpre( video_url, anime_page_url, episode_title ) @@ -1821,31 +2055,34 @@ class AnimeSamaDownloader(BaseAnimeSite): # Validate the extracted URL logger.info(f"Validating extracted URL from {player_name}...") is_valid = await self._test_video_url(video_url_result) - + if is_valid: logger.info(f"SUCCESS: {player_name} returned valid video URL") # Cache this working player for future requests if anime_page_url: self._working_players[anime_page_url] = player_name - logger.debug(f"Cached working player '{player_name}' for anime URL") - + logger.debug( + f"Cached working player '{player_name}' for anime URL" + ) + # Use target_filename if provided if target_filename: filename = target_filename - + return video_url_result, filename else: - logger.warning(f"FAILED: {player_name} returned invalid video URL (validation failed)") + logger.warning( + f"FAILED: {player_name} returned invalid video URL (validation failed)" + ) last_error = f"{player_name} returned invalid URL" continue - + except Exception as e: logger.warning(f"FAILED: {player_name} extraction failed: {str(e)}") last_error = str(e) continue - + # All players failed error_msg = f"All players failed. Last error: {last_error}" logger.error(error_msg) raise Exception(error_msg) - diff --git a/app/downloaders/anime_sites/animeultime.py b/app/downloaders/anime_sites/animeultime.py index 5cde472..37409d8 100644 --- a/app/downloaders/anime_sites/animeultime.py +++ b/app/downloaders/anime_sites/animeultime.py @@ -10,6 +10,10 @@ class AnimeUltimeDownloader(BaseAnimeSite): BASE_DOMAINS = ["anime-ultime.com", "anime-ultime.net", "www.anime-ultime.net"] + def __init__(self): + super().__init__() + self.id = "anime-ultime" + def can_handle(self, url: str) -> bool: return any(domain in url.lower() for domain in self.BASE_DOMAINS) @@ -24,58 +28,79 @@ class AnimeUltimeDownloader(BaseAnimeSite): final_url = str(response.url) # Parse the page - soup = BeautifulSoup(response.text, 'lxml') + soup = BeautifulSoup(response.text, "lxml") # Method 0: Look for og:video meta tag (most reliable for anime-ultime) - og_video = soup.find('meta', property='og:video') - if og_video and og_video.get('content'): - video_url = og_video['content'] - if video_url.endswith('.mp4'): + og_video = soup.find("meta", property="og:video") + if og_video and og_video.get("content"): + video_url = og_video["content"] + if video_url.endswith(".mp4"): filename = self._generate_filename(final_url) print(f"[ANIME-ULTIME] Found og:video link: {video_url}") return video_url, filename # Method 1: Look for direct download links (DDL) # Anime-Ultime often uses links to file hosts - download_links = soup.find_all('a', href=True) + download_links = soup.find_all("a", href=True) for link in download_links: - href = link['href'] + href = link["href"] text = link.get_text().lower() # Look for download buttons/links - if any(keyword in text for keyword in ['télécharger', 'download', 'ddl', 'mega', 'google', 'drive']): + if any( + keyword in text + for keyword in [ + "télécharger", + "download", + "ddl", + "mega", + "google", + "drive", + ] + ): # Check if it's a direct link or to a file host - if any(host in href.lower() for host in ['mega.nz', 'drive.google.com', 'uptobox.com', '1fichier.com']): + if any( + host in href.lower() + for host in [ + "mega.nz", + "drive.google.com", + "uptobox.com", + "1fichier.com", + ] + ): filename = self._generate_filename(final_url) return href, filename # Method 2: Look for iframe with video player - iframes = soup.find_all('iframe') + iframes = soup.find_all("iframe") for iframe in iframes: - src = iframe.get('src', '') - if src and any(provider in src for provider in ['video', 'player', 'stream', 'play']): - if src.startswith('http'): + src = iframe.get("src", "") + if src and any( + provider in src + for provider in ["video", "player", "stream", "play"] + ): + if src.startswith("http"): filename = self._generate_filename(final_url) return src, filename # Method 3: Look for video tags - videos = soup.find_all('video') + videos = soup.find_all("video") for video in videos: - src = video.get('src', '') + src = video.get("src", "") if src: filename = self._generate_filename(final_url) return src, filename # Check source tags - sources = video.find_all('source') + sources = video.find_all("source") for source in sources: - src = source.get('src', '') + src = source.get("src", "") if src: filename = self._generate_filename(final_url) return src, filename # Method 4: Look in scripts for video URLs - scripts = soup.find_all('script') + scripts = soup.find_all("script") for script in scripts: if script.string: # Look for common video patterns @@ -91,26 +116,30 @@ class AnimeUltimeDownloader(BaseAnimeSite): matches = re.findall(pattern, script.string) for match in matches: # Clean up escaped characters - match = match.replace('\\/', '/').replace('\\', '') - if any(ext in match for ext in ['mp4', 'm3u8', 'mkv']): + match = match.replace("\\/", "/").replace("\\", "") + if any(ext in match for ext in ["mp4", "m3u8", "mkv"]): filename = self._generate_filename(final_url) return match, filename # Look for anime-ultime specific patterns # They sometimes store links in JavaScript variables - ddl_match = re.search(r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string) + ddl_match = re.search( + r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string + ) if ddl_match: ddl_url = ddl_match.group(1) - if ddl_url.startswith('http'): + if ddl_url.startswith("http"): filename = self._generate_filename(final_url) return ddl_url, filename # Method 5: Look for links with specific classes or IDs # Anime-Ultime might use specific class names for download links - potential_links = soup.find_all('a', class_=re.compile(r'download|ddl|episode', re.I)) + potential_links = soup.find_all( + "a", class_=re.compile(r"download|ddl|episode", re.I) + ) for link in potential_links: - href = link.get('href', '') - if href and href.startswith('http'): + href = link.get("href", "") + if href and href.startswith("http"): filename = self._generate_filename(final_url) return href, filename @@ -132,36 +161,38 @@ class AnimeUltimeDownloader(BaseAnimeSite): episode = "01" # Format: info-0-1/EPISODE_ID or info-0-1/EPISODE_ID/NAME-EP-vostfr - if 'info-0-1/' in url: + if "info-0-1/" in url: # Extract episode ID - ep_match = re.search(r'info-0-1/(\d+)', url) + ep_match = re.search(r"info-0-1/(\d+)", url) if ep_match: ep_id = ep_match.group(1) # Try to get anime name from URL path - name_match = re.search(r'info-0-1/\d+/([^/]+)', url) + name_match = re.search(r"info-0-1/\d+/([^/]+)", url) if name_match: raw_name = name_match.group(1) # Extract episode number - ep_num_match = re.search(r'-(\d+)-vostfr$', raw_name, re.I) + ep_num_match = re.search(r"-(\d+)-vostfr$", raw_name, re.I) if ep_num_match: episode = ep_num_match.group(1).zfill(2) # Remove episode number and suffix from name - anime_name = re.sub(r'-\d+-vostfr$', '', raw_name, flags=re.I).replace('-', ' ') + anime_name = re.sub( + r"-\d+-vostfr$", "", raw_name, flags=re.I + ).replace("-", " ") else: # Just use the ID anime_name = f"Episode {ep_id}" else: anime_name = f"Episode {ep_id}" - elif 'file-0-1/' in url: + elif "file-0-1/" in url: # Extract from file-0-1/ID-NAME format - file_match = re.search(r'file-0-1/\d+-(.+)$', url) + file_match = re.search(r"file-0-1/\d+-(.+)$", url) if file_match: - anime_name = file_match.group(1).replace('-', ' ') + anime_name = file_match.group(1).replace("-", " ") # Sanitize filename - anime_name = anime_name.replace('/', ' ').strip() + anime_name = anime_name.replace("/", " ").strip() filename = f"{anime_name} - Episode {episode}.mp4" return filename.title() @@ -173,30 +204,30 @@ class AnimeUltimeDownloader(BaseAnimeSite): try: print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}") response = await self.client.get(anime_url) - soup = BeautifulSoup(response.text, 'lxml') + 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': [] + "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.synopsis", + "div.description", 'div[class*="synopsis"]', 'div[class*="synopsis"]', - 'p.synopsis', - '.info', - 'div.texte' + "p.synopsis", + ".info", + "div.texte", ] for selector in synopsis_selectors: @@ -204,68 +235,73 @@ class AnimeUltimeDownloader(BaseAnimeSite): if synopsis_elem: synopsis = synopsis_elem.get_text(strip=True) if len(synopsis) > 50: - metadata['synopsis'] = synopsis + 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'}) + genre_meta = soup.find("meta", property="genre") or soup.find( + "meta", attrs={"name": "genre"} + ) if genre_meta: - genres_text = genre_meta.get('content', '') + genres_text = genre_meta.get("content", "") if genres_text: - metadata['genres'] = [g.strip() for g in genres_text.split(',')] + 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)) + 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) + 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' + "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) + rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text) if rating_match: - metadata['rating'] = f"{rating_match.group(1)}/10" + metadata["rating"] = f"{rating_match.group(1)}/10" break - rating_match = re.search(r'(\d+\.?\d*)\s*/\s*5', rating_text) + 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" + 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) + 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 + metadata["release_year"] = year # Extract poster image from og:image - og_image = soup.find('meta', property='og:image') + og_image = soup.find("meta", property="og:image") if og_image: - metadata['poster_image'] = og_image.get('content') + 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 + metadata["total_episodes"] = episodes_count print(f"[ANIME-ULTIME] Extracted metadata: {metadata}") return metadata @@ -274,7 +310,9 @@ class AnimeUltimeDownloader(BaseAnimeSite): 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]: + 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 @@ -286,27 +324,30 @@ class AnimeUltimeDownloader(BaseAnimeSite): """ try: import time + start = time.time() print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...") # Anime-Ultime uses POST for search search_url = "https://www.anime-ultime.net/search-0-1" - response = await self.client.post(search_url, data={'search': query}) - soup = BeautifulSoup(response.text, 'lxml') + response = await self.client.post(search_url, data={"search": query}) + soup = BeautifulSoup(response.text, "lxml") elapsed = time.time() - start - print(f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s") + print( + f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s" + ) results = [] # Look for search result links - better parsing # Search results use file-0-1/ pattern, not info- - search_results = soup.find_all('a', href=re.compile(r'file-0-1/')) + search_results = soup.find_all("a", href=re.compile(r"file-0-1/")) seen_urls = set() for result in search_results[:10]: # Limit to 10 results - href = result.get('href', '') + href = result.get("href", "") raw_title = result.get_text().strip() # Skip if no href @@ -322,40 +363,44 @@ class AnimeUltimeDownloader(BaseAnimeSite): better_title = raw_title # If raw_title is just "Télécharger" or similar, try to find better title - if len(raw_title) < 5 or raw_title.lower() in ['télécharger', 'download', 'ddl']: + if len(raw_title) < 5 or raw_title.lower() in [ + "télécharger", + "download", + "ddl", + ]: # Try to extract from URL (file-0-1/ID-Title format) - url_match = re.search(r'file-0-1/\d+-(.+)$', href) + url_match = re.search(r"file-0-1/\d+-(.+)$", href) if url_match: - better_title = url_match.group(1).replace('-', ' ').title() + better_title = url_match.group(1).replace("-", " ").title() # If still no good title, look at parent/row elements if len(better_title) < 5: # Check parent row (table structure) - row = result.find_parent(['tr', 'td', 'div']) + row = result.find_parent(["tr", "td", "div"]) if row: # Look for text in the row that's not the link text row_text = row.get_text().strip() # Remove the link text from row text if raw_title in row_text: - row_text = row_text.replace(raw_title, '').strip() + row_text = row_text.replace(raw_title, "").strip() if len(row_text) > 5 and len(row_text) < 100: better_title = row_text # Make URL absolute - if not href.startswith('http'): + if not href.startswith("http"): href = urljoin("https://www.anime-ultime.net/", href) result_item = { - 'title': better_title, - 'url': href, - 'type': 'search_result', - 'metadata': None + "title": better_title, + "url": href, + "type": "search_result", + "metadata": None, } # Fetch metadata if requested if include_metadata: metadata = await self.get_anime_metadata(href) - result_item['metadata'] = metadata + result_item["metadata"] = metadata results.append(result_item) @@ -373,27 +418,27 @@ class AnimeUltimeDownloader(BaseAnimeSite): """ try: response = await self.client.get(anime_url) - soup = BeautifulSoup(response.text, 'lxml') + soup = BeautifulSoup(response.text, "lxml") episodes = [] # Look for episode links - anime-ultime uses info-XXXXX-Name-XX-vostfr format # The URL pattern is info-0-1/ID-Anime-Name-XX-vostfr where XX is episode number - episode_links = soup.find_all('a', href=re.compile(r'info-0-1/\d+')) + episode_links = soup.find_all("a", href=re.compile(r"info-0-1/\d+")) for link in episode_links: - href = link.get('href', '') + href = link.get("href", "") text = link.get_text().strip() # Extract episode number from URL pattern # Matches: info-0-1/30200/Naruto-OAV-01-vostfr - match = re.search(r'-(\d+)-vostfr$', href, re.I) + match = re.search(r"-(\d+)-vostfr$", href, re.I) if not match: # Try other patterns - match = re.search(r'Episode[-\s]?(\d+)', href, re.I) + match = re.search(r"Episode[-\s]?(\d+)", href, re.I) if not match: # Try to extract from text - match = re.search(r'(\d+)', text) + match = re.search(r"(\d+)", text) if match: episode_num = match.group(1).zfill(2) # Pad with zero @@ -401,32 +446,30 @@ class AnimeUltimeDownloader(BaseAnimeSite): # Extract the episode ID from href and build correct URL # href might be "info-0-1/30200" or "info-0-1/30200/..." # We need: https://www.anime-ultime.net/info-0-1/30200 - ep_id_match = re.search(r'info-0-1/(\d+)', href) + ep_id_match = re.search(r"info-0-1/(\d+)", href) if ep_id_match: ep_id = ep_id_match.group(1) # Build the correct episode URL episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}" else: # Fallback to making URL absolute - if not href.startswith('http'): + if not href.startswith("http"): href = urljoin(anime_url, href) episode_url = href - episodes.append({ - 'episode': episode_num, - 'url': episode_url, - 'title': text - }) + episodes.append( + {"episode": episode_num, "url": episode_url, "title": text} + ) # Remove duplicates and sort seen = set() unique_episodes = [] for ep in episodes: - if ep['episode'] not in seen: - seen.add(ep['episode']) + if ep["episode"] not in seen: + seen.add(ep["episode"]) unique_episodes.append(ep) - unique_episodes.sort(key=lambda x: int(x['episode'])) + unique_episodes.sort(key=lambda x: int(x["episode"])) return unique_episodes diff --git a/app/downloaders/anime_sites/frenchmanga.py b/app/downloaders/anime_sites/frenchmanga.py index 173b21c..4ce9ce6 100644 --- a/app/downloaders/anime_sites/frenchmanga.py +++ b/app/downloaders/anime_sites/frenchmanga.py @@ -1,4 +1,5 @@ """French-Manga.net anime streaming site downloader""" + from .base import BaseAnimeSite from bs4 import BeautifulSoup import re @@ -17,11 +18,12 @@ class FrenchMangaDownloader(BaseAnimeSite): "french-manga.net", "w16.french-manga.net", "w15.french-manga.net", - "www.french-manga.net" + "www.french-manga.net", ] def __init__(self): super().__init__() + self.id = "french-manga" self.base_url = "https://w16.french-manga.net" def can_handle(self, url: str) -> bool: @@ -29,9 +31,7 @@ class FrenchMangaDownloader(BaseAnimeSite): return any(domain in url.lower() for domain in self.BASE_DOMAINS) async def search_anime( - self, - query: str, - lang: str = "vostfr" + self, query: str, lang: str = "vostfr" ) -> List[Dict[str, str]]: """ Search for anime on French-Manga. @@ -47,46 +47,50 @@ class FrenchMangaDownloader(BaseAnimeSite): # French-Manga uses a search endpoint search_url = f"{self.base_url}/index.php?do=search" params = { - 'do': 'search', - 'subaction': 'search', - 'story': query, - 'x': '0', - 'y': '0' + "do": "search", + "subaction": "search", + "story": query, + "x": "0", + "y": "0", } response = await self.client.post(search_url, data=params) response.raise_for_status() html = response.text - soup = BeautifulSoup(html, 'lxml') + soup = BeautifulSoup(html, "lxml") results = [] # Look for search results in article or story classes - for item in soup.find_all('article', class_=lambda x: x and 'story' in x.lower()): - title_elem = item.find(['h2', 'h3', 'h4']) - link_elem = item.find('a', href=True) - img_elem = item.find('img') + for item in soup.find_all( + "article", class_=lambda x: x and "story" in x.lower() + ): + title_elem = item.find(["h2", "h3", "h4"]) + link_elem = item.find("a", href=True) + img_elem = item.find("img") if title_elem and link_elem: title = title_elem.get_text(strip=True) - url = link_elem['href'] + url = link_elem["href"] # Ensure absolute URL - if url.startswith('/'): + if url.startswith("/"): url = self.base_url + url cover_image = "" - if img_elem and img_elem.get('src'): - cover_image = img_elem['src'] - if cover_image.startswith('/'): + if img_elem and img_elem.get("src"): + cover_image = img_elem["src"] + if cover_image.startswith("/"): cover_image = self.base_url + cover_image - results.append({ - 'title': title, - 'url': url, - 'cover_image': cover_image, - 'lang': lang - }) + results.append( + { + "title": title, + "url": url, + "cover_image": cover_image, + "lang": lang, + } + ) logger.info(f"Found {len(results)} anime results for query: {query}") return results @@ -96,9 +100,7 @@ class FrenchMangaDownloader(BaseAnimeSite): return [] async def get_episodes( - self, - anime_url: str, - lang: str = "vostfr" + self, anime_url: str, lang: str = "vostfr" ) -> List[Dict[str, str]]: """ Get episode list for an anime. @@ -115,34 +117,36 @@ class FrenchMangaDownloader(BaseAnimeSite): response.raise_for_status() html = response.text - soup = BeautifulSoup(html, 'lxml') + soup = BeautifulSoup(html, "lxml") episodes = [] # Look for episode links (typically in a list or table) # French-Manga usually has episode links in tags with episode numbers - for link in soup.find_all('a', href=True): - href = link['href'] + for link in soup.find_all("a", href=True): + href = link["href"] text = link.get_text(strip=True) # Pattern: Episode links usually contain "episode" or numbers - if re.search(r'episode?\s*\d+', text.lower()): - episode_num = re.search(r'(\d+)', text) + if re.search(r"episode?\s*\d+", text.lower()): + episode_num = re.search(r"(\d+)", text) if episode_num: episode_number = int(episode_num.group(1)) # Ensure absolute URL - if href.startswith('/'): + if href.startswith("/"): href = self.base_url + href - episodes.append({ - 'episode_number': episode_number, - 'url': href, - 'title': text, - 'host': 'french-manga' - }) + episodes.append( + { + "episode_number": episode_number, + "url": href, + "title": text, + "host": "french-manga", + } + ) # Sort by episode number - episodes.sort(key=lambda x: x['episode_number']) + episodes.sort(key=lambda x: x["episode_number"]) logger.info(f"Found {len(episodes)} episodes for {anime_url}") return episodes @@ -166,31 +170,33 @@ class FrenchMangaDownloader(BaseAnimeSite): response.raise_for_status() html = response.text - soup = BeautifulSoup(html, 'lxml') + soup = BeautifulSoup(html, "lxml") # Extract title title = "" - title_elem = soup.find('h1') or soup.find('h2', class_='title') + title_elem = soup.find("h1") or soup.find("h2", class_="title") if title_elem: title = title_elem.get_text(strip=True) # Extract synopsis synopsis = "" - synopsis_elem = soup.find('div', class_=lambda x: x and 'story' in x.lower()) + synopsis_elem = soup.find( + "div", class_=lambda x: x and "story" in x.lower() + ) if synopsis_elem: synopsis = synopsis_elem.get_text(strip=True) # Extract cover image poster_image = "" - img_elem = soup.find('img', class_=lambda x: x and 'poster' in x.lower()) - if img_elem and img_elem.get('src'): - poster_image = img_elem['src'] - if poster_image.startswith('/'): + img_elem = soup.find("img", class_=lambda x: x and "poster" in x.lower()) + if img_elem and img_elem.get("src"): + poster_image = img_elem["src"] + if poster_image.startswith("/"): poster_image = self.base_url + poster_image # Extract genres genres = [] - genre_links = soup.find_all('a', href=re.compile(r'/xfsearch/.*genre/')) + genre_links = soup.find_all("a", href=re.compile(r"/xfsearch/.*genre/")) for link in genre_links[:10]: # Limit to 10 genres genre = link.get_text(strip=True) if genre: @@ -198,36 +204,38 @@ class FrenchMangaDownloader(BaseAnimeSite): # Extract rating (if available) rating = "" - rating_elem = soup.find(['span', 'div'], class_=lambda x: x and 'rating' in x.lower()) + rating_elem = soup.find( + ["span", "div"], class_=lambda x: x and "rating" in x.lower() + ) if rating_elem: rating = rating_elem.get_text(strip=True) return { - 'title': title, - 'synopsis': synopsis, - 'genres': genres, - 'rating': rating, - 'release_year': '', - 'studio': '', - 'poster_image': poster_image, - 'total_episodes': len(await self.get_episodes(anime_url)), - 'status': '', - 'languages': ['vf', 'vostfr'] + "title": title, + "synopsis": synopsis, + "genres": genres, + "rating": rating, + "release_year": "", + "studio": "", + "poster_image": poster_image, + "total_episodes": len(await self.get_episodes(anime_url)), + "status": "", + "languages": ["vf", "vostfr"], } except Exception as e: logger.error(f"Error getting anime metadata: {e}") return { - 'title': '', - 'synopsis': '', - 'genres': [], - 'rating': '', - 'release_year': '', - 'studio': '', - 'poster_image': '', - 'total_episodes': 0, - 'status': '', - 'languages': ['vf', 'vostfr'] + "title": "", + "synopsis": "", + "genres": [], + "rating": "", + "release_year": "", + "studio": "", + "poster_image": "", + "total_episodes": 0, + "status": "", + "languages": ["vf", "vostfr"], } async def get_download_link(self, url: str) -> tuple[str, str]: @@ -248,20 +256,20 @@ class FrenchMangaDownloader(BaseAnimeSite): response.raise_for_status() html = response.text - soup = BeautifulSoup(html, 'lxml') + soup = BeautifulSoup(html, "lxml") # Look for iframe or video player - iframe = soup.find('iframe', src=True) + iframe = soup.find("iframe", src=True) if iframe: - video_url = iframe['src'] + video_url = iframe["src"] else: # Look for video tag directly - video = soup.find('video', src=True) + video = soup.find("video", src=True) if video: - video_url = video['src'] + video_url = video["src"] else: # Try to find in script tags - scripts = soup.find_all('script') + scripts = soup.find_all("script") for script in scripts: if script.string: # Look for iframe or video URLs in JavaScript @@ -274,20 +282,20 @@ class FrenchMangaDownloader(BaseAnimeSite): if match: video_url = match.group(1) break - if 'video_url' in locals(): + if "video_url" in locals(): break - if 'video_url' not in locals(): + if "video_url" not in locals(): raise ValueError("Could not find video player URL") # Ensure absolute URL - if video_url.startswith('//'): - video_url = 'https:' + video_url - elif video_url.startswith('/'): + if video_url.startswith("//"): + video_url = "https:" + video_url + elif video_url.startswith("/"): video_url = self.base_url + video_url # Extract episode title - title_elem = soup.find('h1') or soup.find('h2') + title_elem = soup.find("h1") or soup.find("h2") episode_title = title_elem.get_text(strip=True) if title_elem else "Episode" episode_title = sanitize_filename(episode_title) diff --git a/app/downloaders/anime_sites/nekosama.py b/app/downloaders/anime_sites/nekosama.py index ace4038..a259321 100644 --- a/app/downloaders/anime_sites/nekosama.py +++ b/app/downloaders/anime_sites/nekosama.py @@ -7,79 +7,100 @@ from urllib.parse import urljoin class NekoSamaDownloader(BaseAnimeSite): """Downloader for neko-sama.org (anime streaming via Gupy) - + NOTE: neko-sama.org now redirects to Gupy, which is a legal streaming search engine. It does NOT host video content - it provides metadata about where to watch legally. This provider can search and get metadata but cannot provide direct download links. """ - BASE_DOMAINS = ["neko-sama.org", "www.neko-sama.org", "neko-sama.fr", "nekosama.fr", "www.gupy.fr", "gupy.fr"] + BASE_DOMAINS = [ + "neko-sama.org", + "www.neko-sama.org", + "neko-sama.fr", + "nekosama.fr", + "www.gupy.fr", + "gupy.fr", + ] + + def __init__(self): + super().__init__() + self.id = "neko-sama" def can_handle(self, url: str) -> bool: return any(domain in url.lower() for domain in self.BASE_DOMAINS) - async def get_download_link(self, url: str, target_filename: Optional[str] = None) -> tuple[str, str]: + async def get_download_link( + self, url: str, target_filename: Optional[str] = None + ) -> tuple[str, str]: """ Extract download link from neko-sama URL. - + NOTE: neko-sama.org/Gupy is a legal streaming search engine, NOT a video host. This returns streaming platform information instead of direct video links. """ try: # Check if this is a Gupy URL - if 'gupy.fr' in url or 'neko-sama.org' in url: + if "gupy.fr" in url or "neko-sama.org" in url: response = await self.client.get(url, follow_redirects=True) - soup = BeautifulSoup(response.text, 'lxml') - + soup = BeautifulSoup(response.text, "lxml") + # Look for streaming platform links streaming_links = [] - for link in soup.find_all('a', href=True): - href = link.get('href', '') - if '/out/' in href: + for link in soup.find_all("a", href=True): + href = link.get("href", "") + if "/out/" in href: text = link.get_text(strip=True) - if text and 'Regarder' in text: + if text and "Regarder" in text: streaming_links.append(f"{text}: {href}") - + if streaming_links: - title_elem = soup.find('h1') or soup.find('title') - title = title_elem.get_text(strip=True).split('|')[0].strip() if title_elem else "Unknown" - info = "Available streaming platforms:\n" + "\n".join(streaming_links[:5]) + title_elem = soup.find("h1") or soup.find("title") + title = ( + title_elem.get_text(strip=True).split("|")[0].strip() + if title_elem + else "Unknown" + ) + info = "Available streaming platforms:\n" + "\n".join( + streaming_links[:5] + ) filename = target_filename or f"{title}_streaming_info.txt" return info, filename - - raise Exception("No streaming links found - Gupy is a legal streaming search, not a video host") - + + raise Exception( + "No streaming links found - Gupy is a legal streaming search, not a video host" + ) + # Legacy: try original method for other URLs response = await self.client.get(url, follow_redirects=True) - soup = BeautifulSoup(response.text, 'lxml') + soup = BeautifulSoup(response.text, "lxml") # Method 1: Look for iframes with video - iframes = soup.find_all('iframe') + iframes = soup.find_all("iframe") for iframe in iframes: - src = iframe.get('src', '') - if src and any(p in src for p in ['video', 'player', 'stream']): - if not src.startswith('http'): + src = iframe.get("src", "") + if src and any(p in src for p in ["video", "player", "stream"]): + if not src.startswith("http"): src = urljoin(str(response.url), src) filename = self._generate_filename(str(response.url)) return src, filename # Method 2: Look for video tags - videos = soup.find_all('video') + videos = soup.find_all("video") for video in videos: - src = video.get('src') or video.get('data-src') + src = video.get("src") or video.get("data-src") if src: filename = self._generate_filename(str(response.url)) return src, filename - sources = video.find_all('source') + sources = video.find_all("source") for source in sources: - src = source.get('src', '') + src = source.get("src", "") if src: filename = self._generate_filename(str(response.url)) return src, filename # Method 3: Look in scripts - scripts = soup.find_all('script') + scripts = soup.find_all("script") for script in scripts: if script.string: patterns = [ @@ -90,24 +111,26 @@ class NekoSamaDownloader(BaseAnimeSite): for pattern in patterns: matches = re.findall(pattern, script.string) for match in matches: - match = match.replace('\\/', '/') - if any(ext in match for ext in ['mp4', 'm3u8']): + match = match.replace("\\/", "/") + if any(ext in match for ext in ["mp4", "m3u8"]): filename = self._generate_filename(str(response.url)) return match, filename - raise Exception("Could not find video link - Neko-Sama/Gupy does not host video content") + raise Exception( + "Could not find video link - Neko-Sama/Gupy does not host video content" + ) except Exception as e: raise Exception(f"Error extracting NekoSama link: {str(e)}") def _generate_filename(self, url: str) -> str: - parts = url.split('/') + parts = url.split("/") anime_name = "anime" episode = "1" for i, part in enumerate(parts): - if 'episode' in part.lower(): - match = re.search(r'episode[-\s]*(\d+)', part, re.I) + if "episode" in part.lower(): + match = re.search(r"episode[-\s]*(\d+)", part, re.I) if match: episode = match.group(1) @@ -118,31 +141,31 @@ class NekoSamaDownloader(BaseAnimeSite): """Get list of episodes for an anime.""" try: response = await self.client.get(anime_url) - soup = BeautifulSoup(response.text, 'lxml') + soup = BeautifulSoup(response.text, "lxml") episodes = [] # Try to find episode links - episode_links = soup.find_all('a', href=re.compile(r'episode')) + episode_links = soup.find_all("a", href=re.compile(r"episode")) for link in episode_links: - href = link.get('href', '') - match = re.search(r'episode[-\s]*(\d+)', href, re.I) + href = link.get("href", "") + match = re.search(r"episode[-\s]*(\d+)", href, re.I) if match: episode_num = match.group(1) - if not href.startswith('http'): + if not href.startswith("http"): href = urljoin(anime_url, href) - episodes.append({'episode': episode_num, 'url': href}) + episodes.append({"episode": episode_num, "url": href}) # Deduplicate and sort seen = set() unique_episodes = [] for ep in episodes: - if ep['episode'] not in seen: - seen.add(ep['episode']) + if ep["episode"] not in seen: + seen.add(ep["episode"]) unique_episodes.append(ep) - unique_episodes.sort(key=lambda x: int(x['episode'])) + unique_episodes.sort(key=lambda x: int(x["episode"])) return unique_episodes except Exception as e: @@ -153,70 +176,70 @@ class NekoSamaDownloader(BaseAnimeSite): try: print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}") response = await self.client.get(anime_url) - soup = BeautifulSoup(response.text, 'lxml') + 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': [] + "synopsis": None, + "genres": [], + "rating": None, + "release_year": None, + "studio": None, + "poster_image": None, + "banner_image": None, + "total_episodes": None, + "status": None, + "alternative_titles": [], } # Extract title and year from h1 - title_elem = soup.find('h1') + title_elem = soup.find("h1") if title_elem: title_text = title_elem.get_text(strip=True) # Extract year from title like "Naruto (2002)" - year_match = re.search(r'\((\d{4})\)', title_text) + year_match = re.search(r"\((\d{4})\)", title_text) if year_match: - metadata['release_year'] = int(year_match.group(1)) - + metadata["release_year"] = int(year_match.group(1)) + # Extract synopsis - Gupy shows it as paragraphs - synopsis_elem = soup.find('p') + synopsis_elem = soup.find("p") if synopsis_elem: text = synopsis_elem.get_text(strip=True) if len(text) > 50: - metadata['synopsis'] = text + metadata["synopsis"] = text # Extract genres from meta tags or links - genre_links = soup.find_all('a', href=re.compile(r'serie-|genre|tag')) + genre_links = soup.find_all("a", href=re.compile(r"serie-|genre|tag")) if genre_links: genres = [] for link in genre_links[:5]: text = link.get_text(strip=True) - if text and '/' not in text and len(text) < 30: + if text and "/" not in text and len(text) < 30: genres.append(text) - metadata['genres'] = genres + metadata["genres"] = genres # Extract rating from percentage - rating_elem = soup.find(string=re.compile(r'\d+(\.\d+)?%')) + rating_elem = soup.find(string=re.compile(r"\d+(\.\d+)?%")) if rating_elem: - match = re.search(r'(\d+(\.\d+)?)%', rating_elem) + match = re.search(r"(\d+(\.\d+)?)%", rating_elem) if match: rating = float(match.group(1)) / 10 - metadata['rating'] = f"{rating:.1f}/10" + metadata["rating"] = f"{rating:.1f}/10" # Extract poster image - poster_elem = soup.find('img', src=re.compile(r'poster|poster')) + poster_elem = soup.find("img", src=re.compile(r"poster|poster")) if poster_elem: - metadata['poster_image'] = poster_elem.get('src') + metadata["poster_image"] = poster_elem.get("src") # Extract episode count from page text page_text = soup.get_text() - ep_match = re.search(r'(\d+)\s*episodes?', page_text, re.I) + ep_match = re.search(r"(\d+)\s*episodes?", page_text, re.I) if ep_match: - metadata['total_episodes'] = int(ep_match.group(1)) + metadata["total_episodes"] = int(ep_match.group(1)) # Extract studio/director - director_elem = soup.find('a', href=re.compile(r'person|réalisé')) + director_elem = soup.find("a", href=re.compile(r"person|réalisé")) if director_elem: - metadata['studio'] = director_elem.get_text(strip=True) + metadata["studio"] = director_elem.get_text(strip=True) print(f"[NEKO-SAMA] Extracted metadata: {metadata}") return metadata @@ -225,16 +248,19 @@ class NekoSamaDownloader(BaseAnimeSite): 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]: + async def search_anime( + self, query: str, lang: str = "vostfr", include_metadata: bool = False + ) -> list[dict]: """Search for anime on neko-sama (uses Gupy backend).""" try: import time from html import unescape + start = time.time() print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...") # Neko-Sama now uses Gupy - try the direct URL pattern - search_slug = query.lower().replace(' ', '-') + search_slug = query.lower().replace(" ", "-") search_urls = [ f"https://www.gupy.fr/series/{search_slug}/", f"https://neko-sama.org/series/{search_slug}/", @@ -250,34 +276,40 @@ class NekoSamaDownloader(BaseAnimeSite): print(f"[NEKO-SAMA] Found anime at {final_url}") # Extract title from page - soup = BeautifulSoup(response.text, 'lxml') - title_elem = soup.find('h1') or soup.find('title') - title = unescape(title_elem.get_text(strip=True)) if title_elem else query + soup = BeautifulSoup(response.text, "lxml") + title_elem = soup.find("h1") or soup.find("title") + title = ( + unescape(title_elem.get_text(strip=True)) + if title_elem + else query + ) # Clean up title - title = title.split('|')[0].split('-')[0].strip() + title = title.split("|")[0].split("-")[0].strip() result = { - 'title': title, - 'url': final_url, - 'cover_image': None, - 'type': 'direct', - 'metadata': None + "title": title, + "url": final_url, + "cover_image": None, + "type": "direct", + "metadata": None, } # Try to get poster - poster = soup.find('img', src=re.compile(r'poster')) + poster = soup.find("img", src=re.compile(r"poster")) if poster: - result['cover_image'] = poster.get('src') + result["cover_image"] = poster.get("src") if include_metadata: metadata = await self.get_anime_metadata(final_url) - result['metadata'] = metadata + result["metadata"] = metadata results.append(result) break elapsed = time.time() - start - print(f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results") + print( + f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results" + ) return results except Exception as e: diff --git a/app/downloaders/anime_sites/vostfree.py b/app/downloaders/anime_sites/vostfree.py index 22cf298..8e0355c 100644 --- a/app/downloaders/anime_sites/vostfree.py +++ b/app/downloaders/anime_sites/vostfree.py @@ -9,6 +9,10 @@ class VostfreeDownloader(BaseAnimeSite): BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"] + def __init__(self): + super().__init__() + self.id = "vostfree" + def can_handle(self, url: str) -> bool: return any(domain in url.lower() for domain in self.BASE_DOMAINS) @@ -16,35 +20,35 @@ class VostfreeDownloader(BaseAnimeSite): """Extract download link from vostfree URL""" try: response = await self.client.get(url, follow_redirects=True) - soup = BeautifulSoup(response.text, 'lxml') + soup = BeautifulSoup(response.text, "lxml") # Method 1: Look for iframe players - iframes = soup.find_all('iframe') + iframes = soup.find_all("iframe") for iframe in iframes: - src = iframe.get('src', '') - if src and any(p in src for p in ['player', 'video', 'stream']): - if not src.startswith('http'): + src = iframe.get("src", "") + if src and any(p in src for p in ["player", "video", "stream"]): + if not src.startswith("http"): src = urljoin(str(response.url), src) filename = self._generate_filename(str(response.url)) return src, filename # Method 2: Look for video tags - videos = soup.find_all('video') + videos = soup.find_all("video") for video in videos: - src = video.get('src') + src = video.get("src") if src: filename = self._generate_filename(str(response.url)) return src, filename - sources = video.find_all('source') + sources = video.find_all("source") for source in sources: - src = source.get('src', '') - if src and any(ext in src for ext in ['mp4', 'm3u8']): + src = source.get("src", "") + if src and any(ext in src for ext in ["mp4", "m3u8"]): filename = self._generate_filename(str(response.url)) return src, filename # Method 3: Look in scripts - scripts = soup.find_all('script') + scripts = soup.find_all("script") for script in scripts: if script.string: patterns = [ @@ -56,8 +60,8 @@ class VostfreeDownloader(BaseAnimeSite): for pattern in patterns: matches = re.findall(pattern, script.string) for match in matches: - match = match.replace('\\/', '/') - if any(ext in match for ext in ['mp4', 'm3u8']): + match = match.replace("\\/", "/") + if any(ext in match for ext in ["mp4", "m3u8"]): filename = self._generate_filename(str(response.url)) return match, filename @@ -67,12 +71,12 @@ class VostfreeDownloader(BaseAnimeSite): raise Exception(f"Error extracting Vostfree link: {str(e)}") def _generate_filename(self, url: str) -> str: - parts = url.split('/') + parts = url.split("/") anime_name = "anime" episode = "1" for part in parts: - match = re.search(r'episode[-\s]*(\d+)', part, re.I) + match = re.search(r"episode[-\s]*(\d+)", part, re.I) if match: episode = match.group(1) @@ -82,30 +86,30 @@ class VostfreeDownloader(BaseAnimeSite): async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]: try: response = await self.client.get(anime_url) - soup = BeautifulSoup(response.text, 'lxml') + soup = BeautifulSoup(response.text, "lxml") episodes = [] - episode_links = soup.find_all('a', href=re.compile(r'episode', re.I)) + episode_links = soup.find_all("a", href=re.compile(r"episode", re.I)) for link in episode_links: - href = link.get('href', '') - match = re.search(r'episode[-\s]*(\d+)', href, re.I) + href = link.get("href", "") + match = re.search(r"episode[-\s]*(\d+)", href, re.I) if match: episode_num = match.group(1) - if not href.startswith('http'): + if not href.startswith("http"): href = urljoin(anime_url, href) - episodes.append({'episode': episode_num, 'url': href}) + episodes.append({"episode": episode_num, "url": href}) # Deduplicate and sort seen = set() unique_episodes = [] for ep in episodes: - if ep['episode'] not in seen: - seen.add(ep['episode']) + if ep["episode"] not in seen: + seen.add(ep["episode"]) unique_episodes.append(ep) - unique_episodes.sort(key=lambda x: int(x['episode'])) + unique_episodes.sort(key=lambda x: int(x["episode"])) return unique_episodes except Exception as e: @@ -119,29 +123,29 @@ class VostfreeDownloader(BaseAnimeSite): try: print(f"[VOSTFREE] Extracting metadata from: {anime_url}") response = await self.client.get(anime_url) - soup = BeautifulSoup(response.text, 'lxml') + 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': [] + "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.synopsis", + "div.description", 'div[class*="synopsis"]', 'div[class*="desc"]', - 'p.synopsis', - '.anime-synopsis' + "p.synopsis", + ".anime-synopsis", ] for selector in synopsis_selectors: @@ -149,57 +153,65 @@ class VostfreeDownloader(BaseAnimeSite): if synopsis_elem: synopsis = synopsis_elem.get_text(strip=True) if len(synopsis) > 50: - metadata['synopsis'] = synopsis + metadata["synopsis"] = synopsis break # Extract genres - genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I)) + 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]] + metadata["genres"] = [ + link.get_text(strip=True) for link in genre_links[:5] + ] # Extract rating rating_selectors = [ - 'span.rating', - 'div.rating', - 'span.score', + "span.rating", + "div.rating", + "span.score", 'div[class*="rating"]', - 'div[class*="score"]' + '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) + rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text) if rating_match: - metadata['rating'] = f"{rating_match.group(1)}/10" + 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) + 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] + 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] + + 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') + 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') + 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') + 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 + metadata["total_episodes"] = episodes_count print(f"[VOSTFREE] Extracted metadata: {metadata}") return metadata @@ -208,7 +220,9 @@ class VostfreeDownloader(BaseAnimeSite): print(f"[VOSTFREE] Error extracting metadata: {e}") return {} - async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]: + async def search_anime( + self, query: str, lang: str = "vostfr", include_metadata: bool = False + ) -> list[dict]: """ Search for anime on vostfree @@ -219,6 +233,7 @@ class VostfreeDownloader(BaseAnimeSite): """ try: import time + start = time.time() print(f"[VOSTFREE] Searching for '{query}' ({lang})...") @@ -233,15 +248,15 @@ class VostfreeDownloader(BaseAnimeSite): if response.status_code == 200: print(f"[VOSTFREE] Found anime at {str(response.url)}") result = { - 'title': query, - 'url': str(response.url), - 'type': 'direct', - 'metadata': None + "title": query, + "url": str(response.url), + "type": "direct", + "metadata": None, } if include_metadata: metadata = await self.get_anime_metadata(str(response.url)) - result['metadata'] = metadata + result["metadata"] = metadata return [result] diff --git a/app/downloaders/series_sites/fs7.py b/app/downloaders/series_sites/fs7.py index 7f7dfe1..a5ad7d2 100644 --- a/app/downloaders/series_sites/fs7.py +++ b/app/downloaders/series_sites/fs7.py @@ -1,4 +1,5 @@ """FS7 (French Stream) series site downloader""" + import logging import re from typing import List, Dict, Any, Optional @@ -19,29 +20,46 @@ class FS7Downloader(BaseSeriesSite): def __init__(self): super().__init__() - self.base_url = "https://fs7.lol" - self.search_url = f"{self.base_url}/" - # Update client headers to mimic browser - self.client.headers.update({ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', - 'Accept-Encoding': 'gzip, deflate', - 'Connection': 'keep-alive', - 'Upgrade-Insecure-Requests': '1' - }) + self.id = "fs7" + self.provider_id = "fs7" + self.default_domain = "fs7.lol" + self.test_tlds = ["lol", "com", "net", "org", "tv", "ws", "cc", "co"] + self.base_url = f"https://{self.default_domain}" + self._domain_checked = False + self.client.headers.update( + { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + } + ) + + async def _ensure_base_url(self): + """Ensure base_url is set to the current active domain""" + if self._domain_checked: + return + self._domain_checked = True + try: + from app.utils import DomainManager + + active_domain = await DomainManager.get_active_domain( + self.provider_id, self.default_domain, self.test_tlds, test_path="/" + ) + self.base_url = f"https://{active_domain}" + logger.info(f"Using active domain for FS7: {self.base_url}") + except Exception as e: + logger.warning(f"Domain check failed for FS7, using default: {e}") def can_handle(self, url: str) -> bool: """Check if this downloader can handle the given URL""" return "fs7.lol" in url.lower() or "french-stream" in url.lower() - async def search_anime( - self, - query: str, - lang: str = "vf" - ) -> List[Dict[str, str]]: + async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]: """ - Search for series on FS7. + Search for series on FS7 using DLE AJAX search endpoint. Args: query: Search query @@ -51,91 +69,61 @@ class FS7Downloader(BaseSeriesSite): List of series with title, url, cover_image """ try: + await self._ensure_base_url() logger.info(f"Searching FS7 for: {query}") - # FS7 uses GET request with query parameters for search - response = await self.client.get( - self.search_url, - params={ - "do": "search", - "subaction": "search", - "story": query - } + ajax_url = f"{self.base_url}/engine/ajax/search.php" + response = await self.client.post( + ajax_url, + data={"query": query, "page": "1"}, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "X-Requested-With": "XMLHttpRequest", + "Referer": f"{self.base_url}/", + }, ) response.raise_for_status() html = response.text - soup = BeautifulSoup(html, 'lxml') + soup = BeautifulSoup(html, "lxml") results = [] - # 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[: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: + for item in soup.find_all("div", class_="search-item")[:24]: + onclick = item.get("onclick", "") + url_match = re.search(r"location\.href=['\"]([^'\"]+)['\"]", onclick) + if not url_match: continue - - url = link_elem.get('href', '') - if not url.startswith('http'): + url = url_match.group(1) + if not url.startswith("http"): url = urljoin(self.base_url, url) - # 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: - title = item.get_text(strip=True) + title_elem = item.find("div", class_="search-title") + title = title_elem.get_text(strip=True) if title_elem else "" + title = re.sub(r"\s+", " ", title).strip() - # Extract cover image - 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) - - 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) + poster_elem = item.find("div", class_="search-poster") + if poster_elem: + img = poster_elem.find("img") + if img: + cover_image = ( + img.get("data-src") + or img.get("data-original") + or img.get("src") + or "" + ) if title and len(title) > 2: - if not any(r['url'] == url for r in results): - results.append({ - 'title': title, - 'url': url, - 'cover_image': cover_image - }) + results.append( + { + "title": title, + "url": url, + "cover_image": cover_image, + "provider_id": self.provider_id, + } + ) - logger.info(f"Found {len(results)} series on FS7") + logger.info(f"Found {len(results)} results on FS7 for '{query}'") return results except Exception as e: @@ -143,9 +131,7 @@ class FS7Downloader(BaseSeriesSite): return [] async def get_episodes( - self, - anime_url: str, - lang: str = "vf" + self, anime_url: str, lang: str = "vf" ) -> List[Dict[str, str]]: """ Get episode list for a series. @@ -164,31 +150,33 @@ class FS7Downloader(BaseSeriesSite): response.raise_for_status() html = response.text - soup = BeautifulSoup(html, 'lxml') + soup = BeautifulSoup(html, "lxml") episodes = [] # Get series title for episode naming - title_elem = soup.find('h1') + title_elem = soup.find("h1") series_title = title_elem.get_text(strip=True) if title_elem else "Series" # Clean up title: remove "affiche" suffix - series_title = re.sub(r'\s+affiche$', '', series_title, flags=re.IGNORECASE).strip() + series_title = re.sub( + r"\s+affiche$", "", series_title, flags=re.IGNORECASE + ).strip() # FS7 stores episode data in JavaScript div elements # Format:
- episode_divs = soup.find_all('div', attrs={'data-ep': True}) + episode_divs = soup.find_all("div", attrs={"data-ep": True}) for div in episode_divs: - ep_num = div.get('data-ep', '').strip() + ep_num = div.get("data-ep", "").strip() # Try different video players in order of preference video_url = None host_name = None - for player in ['data-vidzy', 'data-uqload', 'data-voe', 'data-netu']: - player_url = div.get(player, '').strip() + for player in ["data-vidzy", "data-uqload", "data-voe", "data-netu"]: + player_url = div.get(player, "").strip() if player_url: video_url = player_url # Extract host name from attribute name - host_name = player.replace('data-', '').title() + host_name = player.replace("data-", "").title() logger.debug(f"Found episode {ep_num} on {host_name}") break @@ -199,15 +187,19 @@ class FS7Downloader(BaseSeriesSite): # Use pipe-separated format: video_url|anime_url|episode_title combined_url = f"{video_url}|{anime_url}|{episode_title}" - episodes.append({ - 'episode': ep_num, - 'url': combined_url, - 'title': episode_title, - 'host': host_name or 'Unknown' - }) + episodes.append( + { + "episode": ep_num, + "url": combined_url, + "title": episode_title, + "host": host_name or "Unknown", + } + ) # Sort by episode number - episodes.sort(key=lambda x: int(x['episode']) if x['episode'].isdigit() else 0) + episodes.sort( + key=lambda x: int(x["episode"]) if x["episode"].isdigit() else 0 + ) logger.info(f"Found {len(episodes)} episodes") return episodes @@ -216,10 +208,7 @@ class FS7Downloader(BaseSeriesSite): logger.error(f"Error getting episodes from FS7: {e}") return [] - async def get_anime_metadata( - self, - anime_url: str - ) -> Dict[str, Any]: + async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]: """ Get metadata for a series. @@ -236,62 +225,62 @@ class FS7Downloader(BaseSeriesSite): response.raise_for_status() html = response.text - soup = BeautifulSoup(html, 'lxml') + soup = BeautifulSoup(html, "lxml") # Extract title - title = soup.find('h1') + title = soup.find("h1") title = title.get_text(strip=True) if title else "Unknown" # Clean up title: remove "affiche" suffix - title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip() + title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip() # Extract description/synopsis - description_elem = soup.find('div', class_='full-text') - description = description_elem.get_text(strip=True) if description_elem else "" + description_elem = soup.find("div", class_="full-text") + description = ( + description_elem.get_text(strip=True) if description_elem else "" + ) # Extract cover image - img = soup.find('img', class_='poster') - poster_image = img.get('src', '') if img else '' + img = soup.find("img", class_="poster") + poster_image = img.get("src", "") if img else "" # Try to get poster from meta tag if not found if not poster_image: - meta_img = soup.find('meta', property='og:image') - poster_image = meta_img.get('content', '') if meta_img else '' + meta_img = soup.find("meta", property="og:image") + poster_image = meta_img.get("content", "") if meta_img else "" # Extract year - year_match = re.search(r'\b(19|20)\d{2}\b', description) + year_match = re.search(r"\b(19|20)\d{2}\b", description) release_year = int(year_match.group()) if year_match else None return { - 'title': title, - 'synopsis': description, - 'poster_image': poster_image, - 'release_year': release_year, - 'genres': [], - 'rating': None, - 'studio': None, - 'total_episodes': None, - 'status': None + "title": title, + "synopsis": description, + "poster_image": poster_image, + "release_year": release_year, + "genres": [], + "rating": None, + "studio": None, + "total_episodes": None, + "status": None, } except Exception as e: logger.error(f"Error getting metadata from FS7: {e}") return { - 'title': "Unknown", - 'synopsis': "", - 'poster_image': '', - 'genres': [], - 'rating': None, - 'release_year': None, - 'studio': None, - 'total_episodes': None, - 'status': None + "title": "Unknown", + "synopsis": "", + "poster_image": "", + "genres": [], + "rating": None, + "release_year": None, + "studio": None, + "total_episodes": None, + "status": None, } async def get_download_link( - self, - url: str, - target_filename: Optional[str] = None + self, url: str, target_filename: Optional[str] = None ) -> tuple[str, str]: """ Extract download link from video player URL. diff --git a/app/downloaders/series_sites/zonetelechargement.py b/app/downloaders/series_sites/zonetelechargement.py index 269a2fe..ec2706d 100644 --- a/app/downloaders/series_sites/zonetelechargement.py +++ b/app/downloaders/series_sites/zonetelechargement.py @@ -1,4 +1,5 @@ """Zone-Telechargement series site downloader""" + import logging import re from typing import List, Dict, Any, Optional, Tuple @@ -18,94 +19,106 @@ class ZoneTelechargementDownloader(BaseSeriesSite): def __init__(self): super().__init__() + self.id = "zonetelechargement" self.provider_id = "zonetelechargement" - self.default_domain = "zone-telechargement.cam" - self.test_tlds = ["cam", "net", "org", "blue", "lol", "work"] - self.base_url = None # Will be set dynamically - - self.client.headers.update({ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', - }) + self.default_domain = "zone-telechargement.golf" + self.test_tlds = ["golf", "cam", "net", "org", "blue", "lol", "work", "ws"] + self.base_url = f"https://{self.default_domain}" + self._domain_checked = False + + self.client.headers.update( + { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", + } + ) async def _ensure_base_url(self): """Ensure base_url is set to the current active domain""" - if not self.base_url: + if self._domain_checked: + return + self._domain_checked = True + try: active_domain = await DomainManager.get_active_domain( - self.provider_id, - self.default_domain, - self.test_tlds, - test_path="/" + self.provider_id, self.default_domain, self.test_tlds, test_path="/" ) self.base_url = f"https://{active_domain}" logger.info(f"Using active domain for Zone-Telechargement: {self.base_url}") + except Exception as e: + logger.warning( + f"Domain check failed for Zone-Telechargement, using default: {e}" + ) def can_handle(self, url: str) -> bool: """Check if this downloader can handle the given URL""" return "zone-telechargement" in url.lower() or "zt-za" in url.lower() - async def search_anime( - self, - query: str, - lang: str = "vf" - ) -> List[Dict[str, str]]: - """Search for series on Zone-Telechargement""" + async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]: + """Search for series on Zone-Telechargement. + + ZT uses server-side rendered search: GET /?p=series&search=QUERY. + Results are in div.cover_global containers with nested cover_infos_title links. + """ try: await self._ensure_base_url() logger.info(f"Searching Zone-Telechargement for: {query}") - # ZT uses POST or GET for search depending on the version - # Most modern versions use: /index.php?do=search - search_url = f"{self.base_url}/index.php?do=search" - - # Form data for search - data = { - "do": "search", - "subaction": "search", - "search_start": "0", - "full_search": "0", - "result_from": "1", - "story": query - } - - response = await self.client.post(search_url, data=data) + search_url = f"{self.base_url}/" + params = {"p": "series", "search": query} + + response = await self.client.get(search_url, params=params) response.raise_for_status() html = response.text - soup = BeautifulSoup(html, 'lxml') + soup = BeautifulSoup(html, "lxml") results = [] - # Look for items - items = soup.find_all('div', class_='shm-item') or soup.find_all('div', class_='movie-item') - - for item in items[:24]: - link_elem = item.find('a', class_='shm-title') or item.find('a') - if not link_elem: + for cover_div in soup.find_all("div", class_="cover_global")[:24]: + link_in_cover = cover_div.find("a", class_="mainimg") + if not link_in_cover: + link_in_cover = cover_div.find("a") + + if not link_in_cover: continue - url = link_elem.get('href', '') - if not url.startswith('http'): + url = link_in_cover.get("href", "") + if not url.startswith("http"): url = urljoin(self.base_url, url) - title = link_elem.get_text(strip=True) - - img_elem = item.find('img') + img = cover_div.find("img") cover_image = "" - if img_elem: - cover_image = img_elem.get('data-src') or img_elem.get('src') or "" - - if cover_image and not cover_image.startswith('http'): - cover_image = urljoin(self.base_url, cover_image) + if img: + cover_image = img.get("data-src") or img.get("src") or "" + if cover_image and not cover_image.startswith("http"): + cover_image = urljoin(self.base_url, cover_image) + + title = "" + info_div = cover_div.find("div", class_="cover_infos_title") + if info_div: + title_link = info_div.find("a") + if title_link: + title = title_link.get_text(strip=True) + else: + title = info_div.get_text(strip=True) + else: + title = link_in_cover.get("title", "") + if not title: + title = link_in_cover.get_text(strip=True) if title and len(title) > 2: - results.append({ - 'title': title, - 'url': url, - 'cover_image': cover_image, - 'provider_id': self.provider_id - }) + results.append( + { + "title": title, + "url": url, + "cover_image": cover_image, + "provider_id": self.provider_id, + } + ) + logger.info( + f"Zone-Telechargement found {len(results)} results for '{query}'" + ) return results except Exception as e: @@ -113,39 +126,35 @@ class ZoneTelechargementDownloader(BaseSeriesSite): return [] async def get_episodes( - self, - anime_url: str, - lang: str = "vf" + self, anime_url: str, lang: str = "vf" ) -> List[Dict[str, str]]: """Extract episodes from a series page""" try: await self._ensure_base_url() html = await self._fetch_page(anime_url) - soup = BeautifulSoup(html, 'lxml') - + soup = BeautifulSoup(html, "lxml") + episodes = [] - + # ZT typically lists episodes in a table or list of links # Links often look like: /telecharger-series/.../saison-X-episode-Y.html - links = soup.find_all('a', href=re.compile(r'episode-\d+')) - + links = soup.find_all("a", href=re.compile(r"episode-\d+")) + for i, link in enumerate(links): - href = link.get('href', '') - if not href.startswith('http'): + href = link.get("href", "") + if not href.startswith("http"): href = urljoin(self.base_url, href) - + title = link.get_text(strip=True) - ep_match = re.search(r'episode\s*(\d+)', title.lower()) + ep_match = re.search(r"episode\s*(\d+)", title.lower()) ep_number = int(ep_match.group(1)) if ep_match else i + 1 - - episodes.append({ - 'episode_number': ep_number, - 'url': href, - 'title': title - }) - + + episodes.append( + {"episode_number": ep_number, "url": href, "title": title} + ) + # Sort by episode number - episodes.sort(key=lambda x: x['episode_number']) + episodes.sort(key=lambda x: x["episode_number"]) return episodes except Exception as e: @@ -157,32 +166,40 @@ class ZoneTelechargementDownloader(BaseSeriesSite): try: await self._ensure_base_url() html = await self._fetch_page(anime_url) - soup = BeautifulSoup(html, 'lxml') - + soup = BeautifulSoup(html, "lxml") + metadata = { - 'title': "", - 'synopsis': "", - 'genres': [], - 'poster_image': "", - 'status': "Unknown" + "title": "", + "synopsis": "", + "genres": [], + "poster_image": "", + "status": "Unknown", } - - title_elem = soup.find('h1') + + title_elem = soup.find("h1") if title_elem: - metadata['title'] = title_elem.get_text(strip=True) - + metadata["title"] = title_elem.get_text(strip=True) + # Synopsis - syn_elem = soup.find('div', class_='shm-description') or soup.find('div', class_='movie-desc') + syn_elem = soup.find("div", class_="shm-description") or soup.find( + "div", class_="movie-desc" + ) if syn_elem: - metadata['synopsis'] = syn_elem.get_text(strip=True) - + metadata["synopsis"] = syn_elem.get_text(strip=True) + # Poster - img_elem = soup.find('div', class_='shm-img').find('img') if soup.find('div', class_='shm-img') else None + img_elem = ( + soup.find("div", class_="shm-img").find("img") + if soup.find("div", class_="shm-img") + else None + ) if img_elem: - metadata['poster_image'] = urljoin(self.base_url, img_elem.get('src', '')) - + metadata["poster_image"] = urljoin( + self.base_url, img_elem.get("src", "") + ) + return metadata - + except Exception as e: logger.error(f"Error getting metadata from Zone-Telechargement: {e}") return {} @@ -192,19 +209,25 @@ class ZoneTelechargementDownloader(BaseSeriesSite): try: await self._ensure_base_url() html = await self._fetch_page(url) - soup = BeautifulSoup(html, 'lxml') - + soup = BeautifulSoup(html, "lxml") + # Look for video player links (Uptobox, 1fichier, etc.) # ZT often has multiple hosts - links = soup.find_all('a', href=re.compile(r'uptobox|1fichier|doodstream|vidmoly')) - + links = soup.find_all( + "a", href=re.compile(r"uptobox|1fichier|doodstream|vidmoly") + ) + if links: - player_url = links[0].get('href', '') - title = soup.find('h1').get_text(strip=True) if soup.find('h1') else "Episode" + player_url = links[0].get("href", "") + title = ( + soup.find("h1").get_text(strip=True) + if soup.find("h1") + else "Episode" + ) return player_url, title - + return "", "" - + except Exception as e: logger.error(f"Error getting download link from Zone-Telechargement: {e}") return "", "" diff --git a/app/downloaders/video_players/AGENTS.md b/app/downloaders/video_players/AGENTS.md index e94d9b0..1fd9620 100644 --- a/app/downloaders/video_players/AGENTS.md +++ b/app/downloaders/video_players/AGENTS.md @@ -1,16 +1,26 @@ -# Video Players (app/downloaders/video_players) +# Video Players (app/downloaders/video_players/) ## OVERVIEW -File hosting extractors that extract direct download links from video player pages (Doodstream, Sibnet, VidMoly, etc.). +File hosting extractors that extract direct download links from video player pages (Doodstream, Sibnet, VidMoly, Uptobox, etc.). ## WHERE TO LOOK -| Need | File | -|------|------| -| Base class | `base.py` - `BaseVideoPlayer` abstract class | -| Add new player | Create new `.py` file, inherit `BaseVideoPlayer`, add to `__init__.py` | -| URL detection logic | Each player's `can_handle()` method | -| Extract download link | Each player's `get_download_link()` method | +| File | Purpose | +|------|---------| +| `base.py` | `BaseVideoPlayer` abstract class | +| `unfichier.py` | 1fichier.com | +| `doodstream.py` | Doodstream | +| `vidmoly.py` | VidMoly (requires Playwright for extraction) | +| `uptobox.py` | Uptobox | +| `sendvid.py` | SendVid | +| `sibnet.py` | Sibnet | +| `rapidfile.py` | Rapidfile | +| `uqload.py` | Uqload | +| `lpayer.py` | Lplayer | +| `vidzy.py` | Vidzy | +| `luluv.py` | LuLuvid | +| `smoothpre.py` | Smoothpre | +| `oneupload.py` | OneUpload | ## CONVENTIONS @@ -22,16 +32,17 @@ def can_handle(self, url: str) -> bool: ... async def get_download_link(self, url: str, target_filename: str = None) -> tuple[str, str]: ... ``` -**File operation**: Always use `sanitize_filename()` on extracted filenames. - -**HTTP client**: Use `self.client` (AsyncClient from base class). Always close via `await self.close()` when done. - **Return format**: `(download_url, filename)` tuple. +**HTTP client**: Use `self.client` (AsyncClient from base class). Always close via `await self.close()`. + +**File operation**: Always `sanitize_filename()` on extracted filenames. + ## ANTI-PATTERNS -- Do NOT hardcode User-Agent in each player (use base class headers) -- Do NOT forget to call `await self.close()` after extraction -- Do NOT return None for missing URLs, raise an exception -- Do NOT use sync `requests`, use async `httpx` -- Do NOT skip the `target_filename` parameter, even if unused +- Do NOT hardcode User-Agent per player — use base class headers +- Do NOT forget `await self.close()` — resource leak +- Do NOT return None for missing URLs — raise an exception +- Do NOT use sync `requests` — use async `httpx` +- Do NOT skip `target_filename` parameter — required for anime/series site compatibility +- 8 empty `except:` blocks across players — known tech debt diff --git a/app/models/AGENTS.md b/app/models/AGENTS.md new file mode 100644 index 0000000..e291856 --- /dev/null +++ b/app/models/AGENTS.md @@ -0,0 +1,45 @@ +# Models (app/models/) + +## OVERVIEW +SQLModel/Pydantic models combining database tables (SQLModel) and API schemas (Pydantic). Each domain has a Base → Table → Schema pattern. + +## STRUCTURE +``` +models/ +├── __init__.py # Core: DownloadStatus, DownloadTask, DownloadRequest, AnimeMetadata, AnimeSearchResult +├── auth.py # User, UserCreate, UserLogin, Token, UserTable, UserInDB +├── watchlist.py # WatchlistItem, WatchlistSettings, AutoDownloadResult (+ Table variants) +├── sonarr.py # SonarrWebhookPayload, SonarrMapping, SonarrConfig, SonarrSeries (+ Table variants) +├── favorites.py # Favorites-related models +└── settings.py # AppSettings, AppSettingsUpdate (+ Table variant) +``` + +## WHERE TO LOOK + +| Need | File | Key Classes | +|------|------|-------------| +| Download task | `__init__.py` | `DownloadTask`, `DownloadStatus`, `DownloadRequest` | +| Anime metadata | `__init__.py` | `AnimeMetadata`, `AnimeSearchResult` | +| User/auth | `auth.py` | `User`, `UserCreate`, `UserLogin`, `Token`, `UserTable` | +| Watchlist | `watchlist.py` | `WatchlistItem`, `WatchlistSettings`, `WatchlistItemTable` | +| Sonarr | `sonarr.py` | `SonarrWebhookPayload`, `SonarrMapping`, `SonarrConfig`, `SonarrSeries` | +| App settings | `settings.py` | `AppSettings`, `AppSettingsUpdate` | + +## CONVENTIONS + +**Triple-class pattern** (for DB-backed models): +1. `*Base` — Pydantic base with shared fields +2. `*Table` — SQLModel table class (`__tablename__`, `id`, FK columns) +3. Final class — API schema (inherits from both, adds Config) + +**Enums**: PascalCase class, UPPER_SNAKE values (e.g., `DownloadStatus.PENDING`, `WatchlistStatus.ACTIVE`). + +**JSON columns**: Stored as JSON strings in SQLite, accessed via `@property` methods (e.g., `WatchlistItemTable.genres` parses `genres_json`). + +**Config classes**: Each API schema has `class Config: from_attributes = True` for ORM mode. + +## ANTI-PATTERNS + +- Do NOT add new fields to `*Base` without updating corresponding `*Table` and schema classes +- Do NOT use `Optional` for required API fields — use Pydantic defaults +- Empty `except:` in `settings.py:22` — known tech debt diff --git a/app/providers.py b/app/providers.py index 102e2f0..225768f 100644 --- a/app/providers.py +++ b/app/providers.py @@ -3,56 +3,94 @@ ANIME_PROVIDERS = { "anime-sama": { "name": "Anime-Sama", - "domains": ["anime-sama.to", "www.anime-sama.to", "anime-sama.tv", "www.anime-sama.tv", "anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"], + "domains": [ + "anime-sama.to", + "www.anime-sama.to", + "anime-sama.tv", + "www.anime-sama.tv", + "anime-sama.si", + "www.anime-sama.si", + "anime-sama.org", + "anime-sama.store", + "anime-sama.eu", + ], "url_pattern": "https://anime-sama.to/catalogue/{anime}/saison{season}/{lang}/", "icon": "🎬", - "color": "#00d9ff" + "color": "#00d9ff", }, "anime-ultime": { "name": "Anime-Ultime", "domains": ["anime-ultime.net", "anime-ultime.com", "www.anime-ultime.net"], "url_pattern": "https://www.anime-ultime.net/info-{id}-{slug}", "icon": "▶️", - "color": "#00ff88" + "color": "#00ff88", }, "neko-sama": { "name": "Neko-Sama", "domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"], "url_pattern": "https://neko-sama.fr/anime/{slug}", "icon": "🐱", - "color": "#ff6b6b" + "color": "#ff6b6b", }, "vostfree": { "name": "Vostfree", "domains": ["vostfree.tv", "www.vostfree.tv"], "url_pattern": "https://vostfree.tv/anime/{slug}", "icon": "📺", - "color": "#ffd93d" + "color": "#ffd93d", }, "french-manga": { "name": "French-Manga", - "domains": ["french-manga.net", "w16.french-manga.net", "w15.french-manga.net", "www.french-manga.net"], + "domains": [ + "french-manga.net", + "w16.french-manga.net", + "w15.french-manga.net", + "www.french-manga.net", + ], "url_pattern": "https://w16.french-manga.net/{slug}.html", "icon": "🇫🇷", - "color": "#ff7675" - } + "color": "#ff7675", + }, } SERIES_PROVIDERS = { "fs7": { "name": "French Stream", - "domains": ["fs7.lol", "www.fs7.lol", "french-stream.tv", "www.french-stream.tv"], + "domains": [ + "fs7.lol", + "www.fs7.lol", + "french-stream.tv", + "www.french-stream.tv", + "fs7.com", + "fs7.net", + "fs7.org", + "fs7.cc", + "fs7.co", + "french-stream.com", + "french-stream.net", + ], "url_pattern": "https://fs7.lol/s-tv/{slug}.html", "icon": "🎬", - "color": "#ff6b9d" + "color": "#ff6b9d", }, "zonetelechargement": { "name": "Zone-Telechargement", - "domains": ["zone-telechargement.cam", "zone-telechargement.net", "zone-telechargement.org", "zone-telechargement.blue", "zone-telechargement.lol", "zone-telechargement.work"], - "url_pattern": "https://zone-telechargement.cam/index.php?do=search", + "domains": [ + "zone-telechargement.golf", + "zone-telechargement.cam", + "zone-telechargement.net", + "zone-telechargement.org", + "zone-telechargement.blue", + "zone-telechargement.lol", + "zone-telechargement.work", + "zone-telechargement.ws", + "www.zone-telechargement.golf", + "www.zone-telechargement.cam", + ], + "url_pattern": "https://zone-telechargement.golf/index.php?do=search", "icon": "⬇️", - "color": "#00d9ff" - } + "color": "#00d9ff", + }, } FILE_HOSTS = { @@ -60,98 +98,112 @@ FILE_HOSTS = { "name": "1fichier", "domains": ["1fichier.com", "1fichier.fr"], "icon": "📁", - "color": "#4ecdc4" + "color": "#4ecdc4", }, "uptobox": { "name": "Uptobox", "domains": ["uptobox.com", "uptobox.fr"], "icon": "📦", - "color": "#45b7d1" + "color": "#45b7d1", }, "doodstream": { "name": "Doodstream", - "domains": ["doodstream.com", "dood.stream", "dood.to", "dood.lol", "dood.cx", "dood.so", "dood.watch", "dood.sh"], + "domains": [ + "doodstream.com", + "dood.stream", + "dood.to", + "dood.lol", + "dood.cx", + "dood.so", + "dood.watch", + "dood.sh", + ], "icon": "🎥", - "color": "#f7b731" + "color": "#f7b731", }, "rapidfile": { "name": "Rapidfile", "domains": ["rapidfile.net", "rapidfile.com"], "icon": "⚡", - "color": "#ff6b6b" + "color": "#ff6b6b", }, "vidmoly": { "name": "VidMoly", "domains": ["vidmoly.to", "vidmoly.org", "vidmoly.biz"], "icon": "🎬", - "color": "#a29bfe" + "color": "#a29bfe", }, "sendvid": { "name": "SendVid", "domains": ["sendvid.com", "sendvid.io"], "icon": "📤", - "color": "#fd79a8" + "color": "#fd79a8", }, "sibnet": { "name": "Sibnet", "domains": ["sibnet.ru", "video.sibnet.ru"], "icon": "🎞️", - "color": "#00cec9" + "color": "#00cec9", }, "lpayer": { "name": "Lplayer", "domains": ["lpayer.embed4me.com", "lpayer.com", "lplayer.fr"], "icon": "▶️", - "color": "#e17055" + "color": "#e17055", }, "vidzy": { "name": "Vidzy", "domains": ["vidzy.com", "vidzy.net", "www.vidzy.com"], "icon": "🎞️", - "color": "#74b9ff" + "color": "#74b9ff", }, "luluv": { "name": "LuLuvid", "domains": ["luluv.com", "luluvid.com", "www.luluv.com", "www.luluvid.com"], "icon": "🎬", - "color": "#a29bfe" + "color": "#a29bfe", }, "uqload": { "name": "Uqload", "domains": ["uqload.bz", "uqload.com", "www.uqload.bz", "www.uqload.com"], "icon": "📺", - "color": "#fd79a8" + "color": "#fd79a8", }, "smoothpre": { "name": "Smoothpre", "domains": ["smoothpre.com", "www.smoothpre.com"], "icon": "🎬", - "color": "#a29bfe" - } + "color": "#a29bfe", + }, } + def get_all_providers(): """Get all supported providers (anime + series + file hosts)""" return {**ANIME_PROVIDERS, **SERIES_PROVIDERS, **FILE_HOSTS} + def get_anime_providers(): """Get all anime streaming providers""" return ANIME_PROVIDERS + def get_series_providers(): """Get all series streaming providers""" return SERIES_PROVIDERS + def get_file_hosts(): """Get all file hosting providers""" return FILE_HOSTS + def detect_provider_from_url(url: str) -> str | None: """Detect which provider can handle the given URL""" url_lower = url.lower() for provider_id, provider in get_all_providers().items(): - for domain in provider['domains']: + for domain in provider["domains"]: if domain in url_lower: return provider_id diff --git a/app/providers_manager.py b/app/providers_manager.py index 8451a68..4352c0d 100644 --- a/app/providers_manager.py +++ b/app/providers_manager.py @@ -1,4 +1,5 @@ """Manages scraper providers and their health status""" + import os import logging import asyncio @@ -7,6 +8,18 @@ from pathlib import Path from datetime import datetime from app.downloaders.generic_scraper import GenericScraper +from app.downloaders.anime_sites import ( + AnimeSamaDownloader, + NekoSamaDownloader, + AnimeUltimeDownloader, + VostfreeDownloader, + FrenchMangaDownloader, +) +from app.downloaders.series_sites import ( + FS7Downloader, + ZoneTelechargementDownloader, +) +from app.providers import ANIME_PROVIDERS, SERIES_PROVIDERS logger = logging.getLogger(__name__) @@ -16,11 +29,13 @@ class ProvidersManager: def __init__(self, config_dir: str = "app/downloaders/providers_config"): self.config_dir = Path(config_dir) - self.providers: Dict[str, GenericScraper] = {} + self.providers: Dict[str, object] = {} + self.provider_info: Dict[str, Dict] = {} self.health_status: Dict[str, Dict] = {} - self._load_providers() + self._load_yaml_providers() + self._load_hardcoded_providers() - def _load_providers(self): + def _load_yaml_providers(self): """Load all providers from YAML configs""" if not self.config_dir.exists(): logger.warning(f"Providers config directory not found: {self.config_dir}") @@ -33,46 +48,107 @@ class ProvidersManager: self.health_status[scraper.id] = { "status": "unknown", "last_check": None, - "error": None + "error": None, } - logger.info(f"Loaded provider: {scraper.name} ({scraper.id})") + logger.info(f"Loaded YAML provider: {scraper.name} ({scraper.id})") except Exception as e: logger.error(f"Failed to load provider from {config_file}: {e}") + def _load_hardcoded_providers(self): + """Load hardcoded Python providers""" + provider_classes = [ + ("anime-sama", AnimeSamaDownloader, ANIME_PROVIDERS), + ("neko-sama", NekoSamaDownloader, ANIME_PROVIDERS), + ("anime-ultime", AnimeUltimeDownloader, ANIME_PROVIDERS), + ("vostfree", VostfreeDownloader, ANIME_PROVIDERS), + ("french-manga", FrenchMangaDownloader, ANIME_PROVIDERS), + ("fs7", FS7Downloader, SERIES_PROVIDERS), + ("zonetelechargement", ZoneTelechargementDownloader, SERIES_PROVIDERS), + ] + + for provider_id, provider_class, provider_dict in provider_classes: + if provider_id in provider_dict: + try: + self.providers[provider_id] = provider_class() + self.provider_info[provider_id] = provider_dict[provider_id] + self.health_status[provider_id] = { + "status": "unknown", + "last_check": None, + "error": None, + } + logger.info(f"Loaded hardcoded provider: {provider_id}") + except Exception as e: + logger.error(f"Failed to load provider {provider_id}: {e}") + async def check_all_health(self): """Check health of all registered providers""" logger.info("Checking health of all providers...") tasks = [] for provider_id, scraper in self.providers.items(): tasks.append(self._check_single_health(provider_id, scraper)) - + await asyncio.gather(*tasks) logger.info("Provider health check complete") - async def _check_single_health(self, provider_id: str, scraper: GenericScraper): + async def _check_single_health(self, provider_id: str, scraper): """Check health of a single provider and update status""" try: - is_healthy = await scraper.check_health() + is_healthy = await self._do_health_check(scraper) self.health_status[provider_id] = { "status": "up" if is_healthy else "down", "last_check": datetime.now().isoformat(), - "error": None if is_healthy else "No search results returned" + "error": None if is_healthy else "No search results returned", } except Exception as e: self.health_status[provider_id] = { "status": "down", "last_check": datetime.now().isoformat(), - "error": str(e) + "error": str(e), } logger.error(f"Health check failed for {provider_id}: {e}") - def get_provider(self, provider_id: str) -> Optional[GenericScraper]: + async def _do_health_check(self, scraper) -> bool: + """Perform health check on a scraper""" + try: + if hasattr(scraper, "check_health"): + return await scraper.check_health() + elif hasattr(scraper, "client"): + # Test basic connectivity + base_url = getattr(scraper, "base_url", None) or getattr( + scraper, "active_url", None + ) + if base_url: + if hasattr(scraper, "_ensure_base_url"): + await scraper._ensure_base_url() + base_url = getattr(scraper, "base_url", base_url) + response = await scraper.client.get(base_url, timeout=15.0) + return 200 <= response.status_code < 400 + elif hasattr(scraper, "BASE_DOMAINS") and scraper.BASE_DOMAINS: + # Test first domain from BASE_DOMAINS + test_url = f"https://{scraper.BASE_DOMAINS[0]}" + response = await scraper.client.get(test_url, timeout=15.0) + return 200 <= response.status_code < 400 + elif hasattr(scraper, "search_anime"): + results = await scraper.search_anime("One Piece", lang="vostfr") + return len(results) > 0 + elif hasattr(scraper, "search"): + results = await scraper.search("One Piece") + return len(results) > 0 + return False + except Exception as e: + logger.error( + f"Health check exception for {getattr(scraper, 'provider_id', scraper)}: {e}" + ) + return False + + def get_provider(self, provider_id: str): return self.providers.get(provider_id) - def get_active_providers(self) -> List[GenericScraper]: + def get_active_providers(self) -> List: """Return only providers that are UP or UNKNOWN""" return [ - self.providers[pid] for pid, status in self.health_status.items() + self.providers[pid] + for pid, status in self.health_status.items() if status["status"] != "down" ] diff --git a/app/routers/AGENTS.md b/app/routers/AGENTS.md new file mode 100644 index 0000000..fbf890e --- /dev/null +++ b/app/routers/AGENTS.md @@ -0,0 +1,37 @@ +# Routers (app/routers/) + +## OVERVIEW +11 FastAPI APIRouter modules, each owning a URL prefix. All registered in `main.py:118-144`. + +## WHERE TO LOOK + +| Router | File | Prefix | Purpose | +|--------|------|--------|---------| +| root_router | `router_root.py` | `/`, `/web` | Index page, web UI | +| auth_router | `router_auth.py` | `/api/auth` | Register, login, JWT tokens | +| downloads_router | `router_downloads.py` | `/api/download` | Task CRUD, pause/resume, file serve | +| anime_router | `router_anime.py` | `/api/anime`, `/api/series` | Search, metadata, episodes, season download | +| favorites_router | `router_favorites.py` | `/api/favorites` | Favorites toggle, list | +| recommendations_router | `router_recommendations.py` | `/api/recommendations`, `/api/releases` | Personalized + latest releases | +| watchlist_router | `router_watchlist.py` | `/api/watchlist` | Watchlist CRUD, scheduler, auto-download | +| sonarr_router | `router_sonarr.py` | `/api/sonarr`, `/api/webhook/sonarr` | Webhook receiver, mappings | +| player_router | `router_player.py` | `/player`, `/watch` | Video player pages | +| static_router | `router_static.py` | `/static`, `/video` | Static files, video streaming (Range) | +| settings_router | `router_settings.py` | `/api/settings` | User app settings | + +## CONVENTIONS + +**Adding endpoints**: Identify the correct router by URL prefix → add to that file → import in `app/routers/__init__.py` (if new router) → register in `main.py`. + +**Shared dependencies** (via FastAPI `Depends`): +- `download_manager: DownloadManager = Depends(lambda: download_manager)` — singleton from main.py +- `current_user: User = Depends(get_current_user_from_token)` — JWT auth +- `templates: Jinja2Templates = Depends(lambda: templates)` — Jinja2 renderer + +**Router registration** in `main.py` uses `app.include_router(router)`. Tags set per-router for OpenAPI. + +## ANTI-PATTERNS + +- Do NOT create a new router for a single endpoint — add to existing matching router +- Do NOT use `Depends()` with direct module imports that create circular references +- Do NOT duplicate URL prefixes across routers diff --git a/app/routers/router_anime.py b/app/routers/router_anime.py index 0e29f09..997f781 100644 --- a/app/routers/router_anime.py +++ b/app/routers/router_anime.py @@ -9,7 +9,15 @@ import logging import asyncio import hashlib -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + HTTPException, + Query, + Request, + Response, +) from fastapi.templating import Jinja2Templates from sqlmodel import Session, select @@ -35,10 +43,12 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["anime"]) templates = Jinja2Templates(directory="templates") + # Add custom filters to Jinja2 def hash_filter(s): return hashlib.md5(s.encode()).hexdigest()[:10] + templates.env.filters["hash"] = hash_filter @@ -52,6 +62,7 @@ async def get_providers_health(): async def trigger_providers_health_check(background_tasks: BackgroundTasks): """Trigger a manual health check of all providers in the background""" from app.auto_download_scheduler import auto_download_scheduler + background_tasks.add_task(auto_download_scheduler.trigger_health_check_now) return {"status": "Health check triggered in background"} @@ -59,6 +70,7 @@ async def trigger_providers_health_check(background_tasks: BackgroundTasks): def get_download_manager() -> DownloadManager: """Get the download manager instance from main app""" from main import download_manager + return download_manager @@ -73,7 +85,7 @@ async def search_anime_unified( include_metadata: bool = False, html: bool = Query(False), current_user: User = Depends(get_current_user_from_token), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): """ Search across all anime providers. @@ -83,12 +95,14 @@ async def search_anime_unified( start_time = time.time() # Get user settings for disabled providers - statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + statement = select(AppSettingsTable).where( + AppSettingsTable.user_id == current_user.id + ) settings_obj = session.exec(statement).first() disabled_providers = settings_obj.disabled_providers if settings_obj else [] results = {} - + # 1. Prepare search tasks (Generic + Legacy) search_tasks = [] task_metadata = [] @@ -96,19 +110,26 @@ async def search_anime_unified( # Generic YAML providers active_generic = providers_manager.get_active_providers() for provider in active_generic: - if provider.id not in disabled_providers: - search_tasks.append(provider.search(q)) - task_metadata.append({"id": provider.id, "type": "generic"}) + provider_id = getattr(provider, "id", None) + if provider_id and provider_id not in disabled_providers: + if hasattr(provider, "search"): + search_tasks.append(provider.search(q)) + task_metadata.append({"id": provider_id, "type": "generic"}) + elif hasattr(provider, "search_anime"): + search_tasks.append(provider.search_anime(q, lang)) + task_metadata.append({"id": provider_id, "type": "legacy"}) - # Legacy providers + # Legacy providers (already included in providers_manager, but keep for fallback) legacy_downloaders = { "anime-ultime": AnimeUltimeDownloader(), "neko-sama": NekoSamaDownloader(), "vostfree": VostfreeDownloader(), } for pid, dl in legacy_downloaders.items(): - if pid not in disabled_providers: - search_tasks.append(dl.search_anime(q, lang, include_metadata=False)) + if pid not in disabled_providers and pid not in { + getattr(p, "id", None) for p in active_generic + }: + search_tasks.append(dl.search_anime(q, lang)) task_metadata.append({"id": pid, "type": "legacy"}) # 2. Run searches in parallel @@ -118,25 +139,25 @@ async def search_anime_unified( seen_urls = set() enricher = await get_metadata_enricher() enrichment_tasks = [] - enrichment_mapping = [] + enrichment_mapping = [] for i, raw_result in enumerate(all_raw_results): provider_info = task_metadata[i] pid = provider_info["id"] - + if isinstance(raw_result, Exception): logger.error(f"Search failed for {pid}: {raw_result}") continue if not raw_result: continue - + if pid not in results: results[pid] = [] - + for item in raw_result: item_dict = item.model_dump() if hasattr(item, "model_dump") else item url = item_dict.get("url") - + if url and url not in seen_urls: seen_urls.add(url) if q.lower() in (item_dict.get("title") or "").lower(): @@ -144,10 +165,16 @@ async def search_anime_unified( else: item_dict["_relevance_boost"] = 0.5 results[pid].append(item_dict) - + # Prepare enrichment task for top 5 results per provider if len(results[pid]) <= 5: - enrichment_tasks.append(enricher.enrich_metadata(item_dict.get("metadata", {}), item_dict.get("title", ""), url)) + enrichment_tasks.append( + enricher.enrich_metadata( + item_dict.get("metadata", {}), + item_dict.get("title", ""), + url, + ) + ) enrichment_mapping.append((pid, len(results[pid]) - 1)) else: if "metadata" not in item_dict: @@ -170,18 +197,16 @@ async def search_anime_unified( elapsed = time.time() - start_time total_found = sum(len(r) for r in results.values()) - print(f"[SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}") - + print( + f"[SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}" + ) + # 6. Return HTML for HTMX or JSON for API if html or request.headers.get("HX-Request"): print("[SEARCH] Returning HTML response") return templates.TemplateResponse( "components/anime_search_results.html", - { - "request": request, - "results": results, - "settings": settings_obj - } + {"request": request, "results": results, "settings": settings_obj}, ) print("[SEARCH] Returning JSON response") @@ -200,7 +225,7 @@ async def search_series_unified( lang: str = "vf", html: bool = Query(False), current_user: User = Depends(get_current_user_from_token), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): """ Search across all TV series providers (FS7, etc.) @@ -213,14 +238,16 @@ async def search_series_unified( start_time = time.time() # Get user settings for disabled providers - statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + statement = select(AppSettingsTable).where( + AppSettingsTable.user_id == current_user.id + ) settings_obj = session.exec(statement).first() disabled_providers = settings_obj.disabled_providers if settings_obj else [] results = {} series_downloaders = { "fs7": FS7Downloader(), - "zonetelechargement": ZoneTelechargementDownloader() + "zonetelechargement": ZoneTelechargementDownloader(), } search_tasks = [] provider_ids = [] @@ -236,22 +263,24 @@ async def search_series_unified( for provider_id, result in zip(provider_ids, search_results): if isinstance(result, Exception): print(f"[SERIES SEARCH] {provider_id} error: {str(result)}") + logger.error(f"Series search error for {provider_id}: {result}") elif result: results[provider_id] = result + print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results") + else: + print(f"[SERIES SEARCH] {provider_id}: No results returned") 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')}") + 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, - "settings": settings_obj - } + {"request": request, "results": results, "settings": settings_obj}, ) return {"query": q, "lang": lang, "results": results} @@ -266,7 +295,10 @@ async def get_anime_metadata(url: str): 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") + 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)) @@ -284,7 +316,7 @@ async def get_anime_episodes( """ 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" @@ -297,12 +329,12 @@ async def get_anime_episodes( return templates.TemplateResponse( "components/episode_list.html", { - "request": request, - "episodes": episodes, - "anime_url": url, + "request": request, + "episodes": episodes, + "anime_url": url, "anime_title": anime_title, - "lang": lang - } + "lang": lang, + }, ) return {"url": url, "lang": lang, "episodes": episodes} @@ -329,10 +361,17 @@ 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"}}) - + 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} @@ -381,6 +420,7 @@ async def search_anime_mal_details( ): """Search for anime on MyAnimeList and get full details""" from app.recommendations import AnimeReleasesFetcher + fetcher = AnimeReleasesFetcher() try: search_results = await fetcher.search_anime(q, limit=limit) @@ -401,6 +441,7 @@ async def search_anime_mal_details( async def translate_text(request: Request): """Translate text from English to French using Google Translate""" import httpx + try: body = await request.json() text = body.get("text", "") @@ -408,7 +449,13 @@ async def translate_text(request: Request): raise HTTPException(status_code=400, detail="Text is required") async with httpx.AsyncClient(timeout=30.0) as client: url = "https://translate.googleapis.com/translate_a/single" - params = {"client": "gtx", "sl": "en", "tl": "fr", "dt": "t", "q": text[:5000]} + params = { + "client": "gtx", + "sl": "en", + "tl": "fr", + "dt": "t", + "q": text[:5000], + } response = await client.get(url, params=params) if response.status_code == 200: data = response.json() diff --git a/app/routers/router_auth.py b/app/routers/router_auth.py index 9963660..b029f43 100644 --- a/app/routers/router_auth.py +++ b/app/routers/router_auth.py @@ -10,6 +10,7 @@ Endpoints: """ from datetime import datetime, timedelta +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials @@ -39,15 +40,48 @@ async def get_current_user_from_token( headers={"WWW-Authenticate": "Bearer"}, ) - user_dict = user_manager.get_user(username) - if user_dict is None: + user = user_manager.get_user(username) + if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", headers={"WWW-Authenticate": "Bearer"}, ) - return User(**user_dict) + return User( + id=user.id, + username=user.username, + email=user.email, + full_name=user.full_name, + is_active=user.is_active, + created_at=user.created_at, + last_login=user.last_login, + ) + + +async def get_optional_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends( + HTTPBearer(auto_error=False) + ), +) -> Optional[User]: + if credentials is None: + return None + token = credentials.credentials + username = verify_token(token) + if username is None: + return None + user = user_manager.get_user(username) + if user is None: + return None + return User( + id=user.id, + username=user.username, + email=user.email, + full_name=user.full_name, + is_active=user.is_active, + created_at=user.created_at, + last_login=user.last_login, + ) @router.post("/register") @@ -69,15 +103,13 @@ async def register(user_data: UserCreate): ) user_response = User( - id=user["id"], - username=user["username"], - email=user.get("email"), - full_name=user.get("full_name"), - is_active=user["is_active"], - created_at=datetime.fromisoformat(user["created_at"]), - last_login=datetime.fromisoformat(user["last_login"]) - if user.get("last_login") - else None, + id=user.id, + username=user.username, + email=user.email, + full_name=user.full_name, + is_active=user.is_active, + created_at=user.created_at, + last_login=user.last_login, ) return { @@ -111,23 +143,23 @@ async def login(form_data: UserLogin): headers={"WWW-Authenticate": "Bearer"}, ) - if not user.get("is_active", True): + if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled" ) access_token = create_access_token( - data={"sub": user["username"]}, expires_delta=timedelta(days=7) + data={"sub": user.username}, expires_delta=timedelta(days=7) ) return { "access_token": access_token, "token_type": "bearer", "user": { - "id": user["id"], - "username": user["username"], - "email": user.get("email"), - "full_name": user.get("full_name"), + "id": user.id, + "username": user.username, + "email": user.email, + "full_name": user.full_name, }, } @@ -185,7 +217,7 @@ async def refresh_token(refresh_request: dict): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" ) - if not user.get("is_active", True): + if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled" ) diff --git a/app/routers/router_settings.py b/app/routers/router_settings.py index 0ffdee6..0ccf60f 100644 --- a/app/routers/router_settings.py +++ b/app/routers/router_settings.py @@ -1,6 +1,7 @@ """Application settings routes for Ohm Stream Downloader API""" + import json -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.templating import Jinja2Templates from sqlmodel import Session, select @@ -8,7 +9,7 @@ from sqlmodel import Session, select from app.database import get_session from app.models.auth import User, UserTable from app.models.settings import AppSettings, AppSettingsTable, AppSettingsUpdate -from app.routers.router_auth import get_current_user_from_token +from app.routers.router_auth import get_current_user_from_token, get_optional_user from app.providers import get_anime_providers, get_series_providers from app.providers_manager import providers_manager @@ -19,23 +20,25 @@ templates = Jinja2Templates(directory="templates") @router.get("", response_model=AppSettings) async def get_settings( current_user: User = Depends(get_current_user_from_token), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): """Get current application settings for the user""" - statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + statement = select(AppSettingsTable).where( + AppSettingsTable.user_id == current_user.id + ) settings_obj = session.exec(statement).first() - + if not settings_obj: # Create default settings if they don't exist settings_obj = AppSettingsTable(user_id=current_user.id) session.add(settings_obj) session.commit() session.refresh(settings_obj) - + return AppSettings( default_lang=settings_obj.default_lang, theme=settings_obj.theme, - disabled_providers=settings_obj.disabled_providers + disabled_providers=settings_obj.disabled_providers, ) @@ -44,61 +47,69 @@ async def update_settings( update_data: AppSettingsUpdate, response: Response, current_user: User = Depends(get_current_user_from_token), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): """Update application settings for the user""" - statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + statement = select(AppSettingsTable).where( + AppSettingsTable.user_id == current_user.id + ) settings_obj = session.exec(statement).first() - + if not settings_obj: settings_obj = AppSettingsTable(user_id=current_user.id) session.add(settings_obj) - + if update_data.default_lang is not None: settings_obj.default_lang = update_data.default_lang if update_data.theme is not None: settings_obj.theme = update_data.theme if update_data.disabled_providers is not None: settings_obj.disabled_providers = update_data.disabled_providers - + session.add(settings_obj) session.commit() session.refresh(settings_obj) - - response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Paramètres enregistrés", "type": "success"}}) - + + response.headers["HX-Trigger"] = json.dumps( + {"show-toast": {"message": "Paramètres enregistrés", "type": "success"}} + ) + return settings_obj @router.get("/providers/availability") async def get_providers_availability( current_user: User = Depends(get_current_user_from_token), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): """Get list of providers with their availability and enabled status""" # Get user settings - statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + statement = select(AppSettingsTable).where( + AppSettingsTable.user_id == current_user.id + ) settings_obj = session.exec(statement).first() disabled_providers = settings_obj.disabled_providers if settings_obj else [] - + # Get health status health_status = providers_manager.get_all_status() - + # Combine anime and series providers all_providers = {**get_anime_providers(), **get_series_providers()} - + result = [] for pid, info in all_providers.items(): status_info = health_status.get(pid, {"status": "unknown"}) - result.append({ - "id": pid, - "name": info["name"], - "icon": info.get("icon", "🎬"), - "status": status_info["status"], - "enabled": pid not in disabled_providers, - "error": status_info.get("error") - }) - + result.append( + { + "id": pid, + "name": info["name"], + "icon": info.get("icon", "🎬"), + "status": status_info["status"], + "enabled": pid not in disabled_providers, + "error": status_info.get("error"), + } + ) + return result @@ -107,16 +118,18 @@ async def toggle_provider( provider_id: str, response: Response, current_user: User = Depends(get_current_user_from_token), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): """Toggle a provider's enabled/disabled status""" - statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id) + statement = select(AppSettingsTable).where( + AppSettingsTable.user_id == current_user.id + ) settings_obj = session.exec(statement).first() - + if not settings_obj: settings_obj = AppSettingsTable(user_id=current_user.id) session.add(settings_obj) - + disabled = settings_obj.disabled_providers if provider_id in disabled: disabled.remove(provider_id) @@ -124,33 +137,39 @@ async def toggle_provider( else: disabled.append(provider_id) enabled = False - + settings_obj.disabled_providers = disabled session.add(settings_obj) session.commit() - + status_text = "activé" if enabled else "désactivé" - response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"Fournisseur {provider_id} {status_text}", "type": "success"}}) - + response.headers["HX-Trigger"] = json.dumps( + { + "show-toast": { + "message": f"Fournisseur {provider_id} {status_text}", + "type": "success", + } + } + ) + return {"id": provider_id, "enabled": enabled} @router.get("/ui") async def get_settings_ui( request: Request, - current_user: User = Depends(get_current_user_from_token), - session: Session = Depends(get_session) + current_user: Optional[User] = Depends(get_optional_user), + session: Session = Depends(get_session), ): - """Return the settings UI fragment for HTMX""" - # Reuse existing endpoints logic + if current_user is None: + return templates.TemplateResponse( + "components/login_prompt.html", {"request": request} + ) + settings = await get_settings(current_user, session) providers = await get_providers_availability(current_user, session) - + return templates.TemplateResponse( "components/settings_section.html", - { - "request": request, - "settings": settings, - "providers": providers - } + {"request": request, "settings": settings, "providers": providers}, ) diff --git a/app/routers/router_watchlist.py b/app/routers/router_watchlist.py index c1500d4..d97626a 100644 --- a/app/routers/router_watchlist.py +++ b/app/routers/router_watchlist.py @@ -6,7 +6,15 @@ import re import json from typing import List, Optional -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response, Request, Query +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + HTTPException, + Response, + Request, + Query, +) from fastapi.templating import Jinja2Templates from app.download_manager import DownloadManager @@ -20,7 +28,7 @@ from app.models.watchlist import ( WatchlistSettings, WatchlistStatus, ) -from app.routers.router_auth import get_current_user_from_token +from app.routers.router_auth import get_current_user_from_token, get_optional_user router = APIRouter(prefix="/api/watchlist", tags=["watchlist"]) templates = Jinja2Templates(directory="templates") @@ -28,6 +36,7 @@ templates = Jinja2Templates(directory="templates") def get_download_manager() -> DownloadManager: from main import download_manager + return download_manager @@ -41,38 +50,56 @@ async def add_to_watchlist( from main import watchlist_manager try: - existing = watchlist_manager.get_by_anime_url(item_data.anime_url, current_user.id) + existing = watchlist_manager.get_by_anime_url( + item_data.anime_url, current_user.id + ) item = watchlist_manager.add(current_user.id, item_data) - - msg = f"'{item.anime_title}' ajouté à la watchlist" if not existing else f"'{item.anime_title}' est déjà dans la watchlist" + + msg = ( + f"'{item.anime_title}' ajouté à la watchlist" + if not existing + else f"'{item.anime_title}' est déjà dans la watchlist" + ) toast_type = "success" if not existing else "info" - response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": msg, "type": toast_type}}) - + response.headers["HX-Trigger"] = json.dumps( + {"show-toast": {"message": msg, "type": toast_type}} + ) + return item except Exception as e: from logging import getLogger + logger = getLogger(__name__) logger.error(f"Error adding to watchlist: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) -@router.get("", response_model=List[WatchlistItem]) +@router.get("") async def get_watchlist( request: Request, status: Optional[WatchlistStatus] = None, html: bool = Query(False), - current_user: User = Depends(get_current_user_from_token), + current_user: Optional[User] = Depends(get_optional_user), ): - """Get user's watchlist (supports HTML for HTMX)""" from main import watchlist_manager - items = watchlist_manager.get_all(user_id=current_user.id, status=status) - - if html or request.headers.get("HX-Request"): + + is_htmx = request.headers.get("HX-Request") + + if current_user is None and (html or is_htmx): return templates.TemplateResponse( - "components/watchlist_items_list.html", - {"request": request, "items": items} + "components/login_prompt.html", {"request": request} ) - + + if current_user is None: + raise HTTPException(status_code=401, detail="Authentication required") + + items = watchlist_manager.get_all(user_id=current_user.id, status=status) + + if html or is_htmx: + return templates.TemplateResponse( + "components/watchlist_items_list.html", {"request": request, "items": items} + ) + return items @@ -82,6 +109,7 @@ async def get_watchlist_settings( ): """Get global watchlist settings""" from main import watchlist_manager + return watchlist_manager.get_settings() @@ -97,9 +125,18 @@ async def update_watchlist_settings( try: updated_settings = watchlist_manager.update_settings(settings) if auto_download_scheduler.is_running(): - auto_download_scheduler.update_interval(updated_settings.check_interval_hours) - - response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Paramètres de la watchlist mis à jour", "type": "success"}}) + auto_download_scheduler.update_interval( + updated_settings.check_interval_hours + ) + + response.headers["HX-Trigger"] = json.dumps( + { + "show-toast": { + "message": "Paramètres de la watchlist mis à jour", + "type": "success", + } + } + ) return updated_settings except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -112,6 +149,7 @@ async def get_watchlist_item( ): """Get a specific watchlist item""" from main import watchlist_manager + item = watchlist_manager.get_by_id(item_id) if not item or item.user_id != current_user.id: raise HTTPException(status_code=404, detail="Item not found") @@ -133,8 +171,15 @@ async def update_watchlist_item( raise HTTPException(status_code=404, detail="Item not found") updated_item = watchlist_manager.update(item_id, update_data) - - response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"'{updated_item.anime_title}' mis à jour", "type": "success"}}) + + response.headers["HX-Trigger"] = json.dumps( + { + "show-toast": { + "message": f"'{updated_item.anime_title}' mis à jour", + "type": "success", + } + } + ) return updated_item @@ -153,10 +198,17 @@ async def delete_from_watchlist( title = item.anime_title if watchlist_manager.delete(item_id): - response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"'{title}' supprimé de la watchlist", "type": "info"}}) + response.headers["HX-Trigger"] = json.dumps( + { + "show-toast": { + "message": f"'{title}' supprimé de la watchlist", + "type": "info", + } + } + ) # HTMX will handle removing the element if target is specified in the frontend return Response(status_code=204) - + raise HTTPException(status_code=500, detail="Failed to delete item") @@ -168,10 +220,17 @@ async def check_watchlist_now( ): """Trigger an immediate check for new episodes""" from main import auto_download_scheduler - + background_tasks.add_task(auto_download_scheduler.trigger_check_now) - response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Vérification de la watchlist lancée en arrière-plan", "type": "info"}}) - + response.headers["HX-Trigger"] = json.dumps( + { + "show-toast": { + "message": "Vérification de la watchlist lancée en arrière-plan", + "type": "info", + } + } + ) + return {"status": "success", "message": "Check triggered"} @@ -181,4 +240,5 @@ async def get_watchlist_stats( ): """Get watchlist statistics for the user""" from main import watchlist_manager + return watchlist_manager.get_stats(current_user.id) diff --git a/main.py b/main.py index 620a2de..2d6148f 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ import uuid from datetime import datetime from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -21,10 +21,18 @@ from app.models import DownloadTask, DownloadStatus # Configure logging logger = logging.getLogger(__name__) +PERMISSIONS_POLICY_VALUE = ( + "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), " + "camera=(), display-capture=(), document-domain=(), encrypted-media=(), " + "fullscreen=*, gamepad=(), geolocation=(), gyroscope=(), " + "magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=*, " + "publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), " + "usb=(), web-share=(), xr-spatial-tracking=()" +) + # Initialize FastAPI app app = FastAPI(title="Ohm Stream Downloader") -# Configure CORS app.add_middleware( CORSMiddleware, allow_origins=[ @@ -40,6 +48,14 @@ app.add_middleware( allow_headers=["*"], ) + +@app.middleware("http") +async def permissions_policy_middleware(request: Request, call_next): + response: Response = await call_next(request) + response.headers["Permissions-Policy"] = PERMISSIONS_POLICY_VALUE + return response + + # Initialize download manager download_manager = DownloadManager(download_dir="downloads", max_parallel=3) @@ -54,6 +70,7 @@ async def startup_event(): """Initialize services on application startup""" # Create database tables if they don't exist from app.database import create_db_and_tables + create_db_and_tables() logger.info("Database tables initialized") diff --git a/static/css/style.css b/static/css/style.css index 23fb764..b222dc6 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -170,140 +170,106 @@ h1 { .btn-watch { background: var(--primary); color: #000; } .btn-download { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; } -/* Horizontal Rows (Netflix Style) */ -.streaming-row { +/* Horizontal Scroll Row (Homepage) */ +.home-row, .streaming-row, .recommendations-carousel, .releases-carousel { display: flex; - gap: 20px; + gap: 16px; overflow-x: auto; - padding: 20px 5px; + padding: 10px 0 20px; scroll-behavior: smooth; -webkit-overflow-scrolling: touch; } - -.streaming-row::-webkit-scrollbar { - height: 6px; +.home-row::-webkit-scrollbar, .streaming-row::-webkit-scrollbar, .recommendations-carousel::-webkit-scrollbar, .releases-carousel::-webkit-scrollbar { + height: 4px; } - -.streaming-row::-webkit-scrollbar-thumb { +.home-row::-webkit-scrollbar-thumb, .streaming-row::-webkit-scrollbar-thumb, .recommendations-carousel::-webkit-scrollbar-thumb, .releases-carousel::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 10px; } - -.streaming-row::-webkit-scrollbar-thumb:hover { +.home-row::-webkit-scrollbar-thumb:hover, .streaming-row::-webkit-scrollbar-thumb:hover, .recommendations-carousel::-webkit-scrollbar-thumb:hover, .releases-carousel::-webkit-scrollbar-thumb:hover { background: var(--primary); } -/* Modern Card Design */ -.anime-card { - flex: 0 0 220px; +/* Home Card */ +.hc { + flex: 0 0 180px; + display: block; background: var(--bg-card); border-radius: var(--card-radius); overflow: hidden; transition: var(--transition); - position: relative; border: 1px solid rgba(255, 255, 255, 0.05); - display: flex; - flex-direction: column; + text-decoration: none; + color: inherit; } - -.anime-card:hover { - transform: scale(1.05); +.hc:hover { + transform: scale(1.08); z-index: 10; border-color: var(--primary); - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6); } - -.anime-poster { +.hc-poster { position: relative; padding-top: 150%; background: #000; } - -.anime-poster img { +.hc-poster img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; - transition: var(--transition); } - -.anime-rating-badge { +.hc-rating { position: absolute; - top: 10px; right: 10px; - background: rgba(0, 0, 0, 0.8); + top: 8px; right: 8px; + background: rgba(0, 0, 0, 0.85); color: #ffcc00; - padding: 4px 8px; - border-radius: 6px; - font-size: 0.75rem; + padding: 2px 7px; + border-radius: 4px; + font-size: 0.7rem; font-weight: 800; - backdrop-filter: blur(5px); - border: 1px solid rgba(255, 204, 0, 0.2); } - -.anime-overlay { +.hc-play { position: absolute; - top: 0; left: 0; width: 100%; height: 100%; - background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0) 100%); + bottom: 8px; right: 8px; + width: 32px; height: 32px; + border-radius: 50%; + background: var(--primary); + color: var(--bg-dark); display: flex; - flex-direction: column; - justify-content: flex-end; - padding: 15px; + align-items: center; + justify-content: center; + font-size: 0.75rem; opacity: 0; transition: var(--transition); } - -.anime-card:hover .anime-overlay { opacity: 1; } - -.overlay-buttons { - display: flex; - gap: 10px; - justify-content: center; +.hc:hover .hc-play { opacity: 1; } +.hc-info { + padding: 10px; } - -/* Info Area */ -.anime-info { - padding: 12px; - flex-grow: 1; - display: flex; - flex-direction: column; +.hc-src { + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + color: var(--primary); + letter-spacing: 0.5px; + display: block; + margin-bottom: 2px; } - -.anime-title { - font-size: 0.95rem; +.hc-title { + font-size: 0.82rem; font-weight: 600; - margin-bottom: 8px; + display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + color: var(--text-main); } -.anime-meta-tags { - display: flex; - gap: 5px; - margin-bottom: 12px; -} - -.badge { - font-size: 0.65rem; - font-weight: 700; - text-transform: uppercase; - padding: 2px 6px; - border-radius: 4px; - background: rgba(255, 255, 255, 0.1); - color: var(--text-dim); -} - -/* Action Buttons Grid */ -.anime-card-buttons { +/* Responsive Grid for Search */ +.anime-grid { display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - margin-bottom: 10px; -} - -.btn-add-watchlist.followed { - border-color: var(--accent); - color: var(--accent); - background: rgba(0, 255, 136, 0.1); + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 20px; } /* Tabs UI */ @@ -640,23 +606,17 @@ h1 { opacity: 0.15; } +.htmx-indicator { display: none; } +.htmx-indicator.htmx-request { display: flex; align-items: center; gap: 8px; } + /* Section Containers */ .section-container { margin-bottom: 50px; } -/* Responsive Grid for Search */ -.anime-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 30px; -} - @media (max-width: 768px) { - .anime-card { flex: 0 0 160px; } - .anime-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; } - .btn-card span { display: none; } - .btn-card { padding: 10px; } + .anime-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 12px; } + .hc { flex: 0 0 140px; } .tabs { gap: 10px; } .auth-panel { flex-direction: column; gap: 15px; text-align: center; } } diff --git a/static/js/AGENTS.md b/static/js/AGENTS.md new file mode 100644 index 0000000..60ee1c0 --- /dev/null +++ b/static/js/AGENTS.md @@ -0,0 +1,56 @@ +# Frontend JS (static/js/) + +## OVERVIEW +Vanilla JavaScript modules loaded via ` + @@ -46,14 +56,15 @@ isAuthenticated: true, username: '', init() { - console.log('Global app state ready'); window.addEventListener('auth-success', (e) => { - console.log('Alpine auth-success received'); this.isAuthenticated = true; this.username = e.detail.username; }); + window.addEventListener('auth-logout', () => { + this.isAuthenticated = false; + this.username = ''; + }); window.addEventListener('set-tab', (e) => { - console.log('Alpine set-tab received:', e.detail.tab); this.activeTab = e.detail.tab; }); } diff --git a/templates/components/anime_card.html b/templates/components/anime_card.html index 2fa1b2a..a5beb66 100644 --- a/templates/components/anime_card.html +++ b/templates/components/anime_card.html @@ -1,71 +1,18 @@ {% macro anime_card(anime, in_watchlist=False, lang='vostfr') %} -
-
+
+
{% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/161625/00d9ff?text=No+Image' %} - {{ anime.title }} - + {{ anime.title }} {% if anime.metadata and anime.metadata.rating %} -
- {{ anime.metadata.rating }} -
+ {{ anime.metadata.rating }} {% endif %} - -
-
- -
-
+
- -
-

{{ anime.title }}

- -
- {{ anime.provider_id or 'Anime' }} - {{ lang | upper }} - {% if anime.metadata and anime.metadata.status %} - {{ anime.metadata.status }} - {% endif %} -
- -
- - -
- - {% if not in_watchlist %} - - {% else %} - - {% endif %} +
+ {{ anime.provider_id or 'Anime' }} + {{ anime.title }}
{% endmacro %} diff --git a/templates/components/anime_search_results.html b/templates/components/anime_search_results.html index aad5da7..6343880 100644 --- a/templates/components/anime_search_results.html +++ b/templates/components/anime_search_results.html @@ -1,38 +1,156 @@ -{% from "components/anime_card.html" import anime_card %} +{% set accent = "#00d9ff" %} +{% set default_lang = settings.default_lang if settings else 'vostfr' %} -
- {% if results %} - {% for provider_id, items in results.items() %} -
-

{{ provider_id | upper }}

-
- {% for anime in items %} - {{ anime_card(anime, lang=settings.default_lang if settings else 'vostfr') }} - {% endfor %} +{% set _groups = namespace(items={}) %} +{% for pid, items in (results or {}).items() %} + {% for item in items %} + {% set _key = item.title | lower | trim %} + {% if _key not in _groups.items %} + {% set _ = _groups.items.update({_key: { + "title": item.title, + "cover": item.cover_image or (item.metadata.poster_image if item.metadata else "") or "", + "synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else "")[:300], + "rating": item.metadata.rating if item.metadata and item.metadata.rating else "", + "genres": item.metadata.genres if item.metadata and item.metadata.genres else [], + "providers": [{ "id": item.provider_id or pid, "url": item.url }] + }}) %} + {% else %} + {% set _existing = _groups.items[_key] %} + {% if not _existing.cover and item.cover_image %} + {% set _ = _existing.update({"cover": item.cover_image}) %} + {% endif %} + {% if not _existing.synopsis and item.metadata and item.metadata.synopsis %} + {% set _ = _existing.update({"synopsis": item.metadata.synopsis[:300]}) %} + {% endif %} + {% if not _existing.rating and item.metadata and item.metadata.rating %} + {% set _ = _existing.update({"rating": item.metadata.rating}) %} + {% endif %} + {% set _ = _existing["providers"].append({"id": item.provider_id or pid, "url": item.url}) %} + {% endif %} + {% endfor %} +{% endfor %} + +
+ {% if _groups.items.values() | list | length > 0 %} + {% for group in _groups.items.values() | list %} + {% set first_url = group.providers[0].url %} + {% endfor %} {% else %} -
+
-

Aucun anime trouvé pour votre recherche.

+

Aucun anime trouve pour votre recherche.

{% endif %}
diff --git a/templates/components/header.html b/templates/components/header.html index f7bb119..57f9553 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -9,8 +9,9 @@ Connecté en tant que -
diff --git a/templates/components/home_section.html b/templates/components/home_section.html index f01ff84..367954f 100644 --- a/templates/components/home_section.html +++ b/templates/components/home_section.html @@ -1,9 +1,5 @@ -
- - -

🎯 Recommandé pour vous

@@ -16,12 +12,11 @@
-
+ class="home-row"> +
-

🔥 Dernières sorties

@@ -34,8 +29,8 @@
-
+ class="home-row"> +
diff --git a/templates/components/login_prompt.html b/templates/components/login_prompt.html new file mode 100644 index 0000000..5ed18bc --- /dev/null +++ b/templates/components/login_prompt.html @@ -0,0 +1,4 @@ + diff --git a/templates/components/series_card.html b/templates/components/series_card.html index 08f1aac..9bac403 100644 --- a/templates/components/series_card.html +++ b/templates/components/series_card.html @@ -1,58 +1,18 @@ {% macro series_card(series, in_watchlist=False, lang='vf') %} -
-
- {{ series.title }} -
-
- -
-
+
+
+ {{ series.title }} +
-
-

{{ series.title }}

-
- {{ series.provider_id | upper if series.provider_id else 'FS7' }} - {{ lang | upper }} -
- -
- - -
- - {% if not in_watchlist %} - - {% else %} - - {% endif %} +
+ {{ series.provider_id | upper if series.provider_id else 'FS7' }} +

{{ series.title }}

{% endmacro %} diff --git a/templates/components/series_search_results.html b/templates/components/series_search_results.html index 1a8471c..62ae650 100644 --- a/templates/components/series_search_results.html +++ b/templates/components/series_search_results.html @@ -1,38 +1,130 @@ -{% from "components/series_card.html" import series_card %} +{% set accent = "#ff6b6b" %} +{% set default_lang = settings.default_lang if settings else 'vf' %} -
- {% if results %} - {% for provider_id, items in results.items() %} -
-

{{ provider_id | upper }}

-
- {% for series in items %} - {{ series_card(series, lang=settings.default_lang if settings else 'vf') }} - {% endfor %} +{% set _groups = namespace(items={}) %} +{% for pid, items in (results or {}).items() %} + {% for item in items %} + {% set _key = item.title | lower | trim %} + {% if _key not in _groups.items %} + {% set _ = _groups.items.update({_key: { + "title": item.title, + "cover": item.cover_image or "", + "synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else "")[:300], + "providers": [{ "id": item.provider_id or pid, "url": item.url }] + }}) %} + {% else %} + {% set _existing = _groups.items[_key] %} + {% if not _existing.cover and item.cover_image %} + {% set _ = _existing.update({"cover": item.cover_image}) %} + {% endif %} + {% set _ = _existing["providers"].append({"id": item.provider_id or pid, "url": item.url}) %} + {% endif %} + {% endfor %} +{% endfor %} + +
+ {% if _groups.items.values() | list | length > 0 %} + {% for group in _groups.items.values() | list %} + {% set first_url = group.providers[0].url %} +
+ + {{ group.title }} + +
+

{{ group.title }}

+ + {% if group.synopsis %} +

{{ group.synopsis[:200] }}{% if group.synopsis | length > 200 %}...{% endif %}

+ {% endif %} + +
+ {% for p in group.providers %} + {{ p.id | upper }} + {% endfor %} +
+ +
+ + Regarder + +
+ +
+ + +
+
+
+ +
{% endfor %} {% else %} -
+
-

Aucune série TV trouvée pour votre recherche.

+

Aucune serie TV trouvee pour votre recherche.

{% endif %}
diff --git a/templates/index.html b/templates/index.html index 949ac8c..d006eb6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -35,7 +35,7 @@ Rechercher -