feat: fix auth, provider health checks, search, and redesign UI
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

- 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
This commit is contained in:
root
2026-03-28 00:14:31 +00:00
parent 5d23a3d663
commit 3dc5dd8fe9
36 changed files with 2735 additions and 1989 deletions
+122 -359
View File
@@ -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 ```bash
# Setup # Dev server
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
# Run dev server
uvicorn main:app --reload --host 0.0.0.0 --port 3000 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 # Single file / class / test
# 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
pytest tests/test_sonarr.py -v pytest tests/test_sonarr.py -v
# Specific class
pytest tests/test_sonarr.py::TestSonarrHandler -v pytest tests/test_sonarr.py::TestSonarrHandler -v
# Specific test
pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
# Pattern match # Debug
pytest -k "test_download" -v 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) ### Imports
1. Standard library (`os`, `json`, `asyncio`) Three groups separated by blank lines: stdlib → third-party → local (`app.*`).
2. Third-party (`httpx`, `beautifulsoup4`, `fastapi`) Always absolute imports (no relative). No wildcard imports (`from X import *` enforced).
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
```
### Formatting ### Formatting
- **Line length**: 120 chars max PEP 8, 120 chars max line length. Single quotes for strings, double quotes for docstrings.
- **Indentation**: 4 spaces Ruff handles linting and formatting (no local config — CI-only).
- **Blank lines**: 2 between top-level, 1 between inline
### Type Annotations ### Types
- Use explicit types Explicit type hints on all function signatures and return types.
- Use `Optional[X]` not `X | None` Use `Optional[X]` from `typing`. Use `list[str]` / `dict[str, X]` (lowercase generics).
- Use `list[X]`, `dict[X, Y]` Pydantic models for all API schemas. Return type annotations required on public methods.
```python ### Naming
# Good - `snake_case` for functions, variables, constants
async def get_download_link(url: str, target_filename: Optional[str] = None) -> tuple[str, str]: - `PascalCase` for classes and enums
results: list[dict[str, str]] = [] - `UPPER_SNAKE_CASE` for enum values (`DownloadStatus.PENDING`)
- `logger = logging.getLogger(__name__)` at module level
# Avoid - `_` prefix for private methods (`_fetch_page`, `_sanitize`)
async def get_download_link(url, target_filename=None): - `get_*` for factory functions (`get_downloader`, `get_anime_site`)
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()
```
### Error Handling ### Error Handling
- Use try/except for recoverable errors - `HTTPException` for API errors with proper status codes
- Raise specific exceptions (`HTTPException`, `ValueError`) - `raise ValueError()` for business logic validation
- Never use empty except blocks - `try/except` with logging — never bare `except:` (known tech debt exists)
- Log errors appropriately - `response.raise_for_status()` for HTTP errors
- Never return `None` for missing URLs from downloaders — raise an exception
```python ### Docstrings
try: Triple double quotes `"""`. Module docstrings describe purpose. Class docstrings describe
result = await client.get(url) responsibility. Complex methods use Args/Returns sections. Not all methods require docstrings.
except httpx.TimeoutException:
logger.warning(f"Request timeout for {url}") ## ARCHITECTURE
raise HTTPException(status_code=504, detail="Request timeout")
```
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 ## KEY CONVENTIONS
- Always sanitize filenames: `app.utils.sanitize_filename()`
- Validate paths: `app.utils.is_safe_filename()`
### Testing - **URL pipe format**: `video_url|anime_page_url|episode_title` — metadata preserved through download pipeline
- Use pytest with pytest-asyncio - **Module-level init**: `database.py` creates engine on import; `main.py` creates `download_manager` on import
- Mark tests: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.slow`, `@pytest.mark.network` - **Async everywhere**: Always `async/await` for I/O — use `httpx.AsyncClient`, never `requests`
- Tests in `test_api.py` are auto-marked as integration, others as unit - **Factory pattern**: `get_downloader(url)` routes anime → series → video player → generic
- Use fixtures from `tests/conftest.py` - **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 ## ANTI-PATTERNS (DO NOT)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_download_manager():
manager = DownloadManager(max_parallel=3)
assert manager.max_parallel == 3
# Mark slow tests - Use sync `requests` — always `httpx.AsyncClient`
@pytest.mark.slow - Return `None` for missing URLs from downloaders — raise an exception
async def test_full_download_flow(): - 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 ## TEST CONVENTIONS
@pytest.mark.network
async def test_external_api():
...
```
### Security - `tests/` directory with `conftest.py` for shared fixtures
- Never hardcode secrets - use environment variables - Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.network`, `@pytest.mark.slow`
- Validate all inputs (URLs, filenames) - `asyncio_mode = auto` — async test functions run without explicit marker
- Use HMAC for webhook verification when configured - Test naming: `test_<verb>_<noun>` in `Test*` classes
- Limit CORS origins - never use `*` in production - 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/`) ## NOTES
- `animesama.py` - Anime-Sama (primary)
- `animeultime.py` - Anime-Ultime
- `nekosama.py` - Neko-Sama
- `vostfree.py` - Vostfree
- `frenchmanga.py` - French-Manga
2. **Series Catalogs** (`app/downloaders/series_sites/`) - Python 3.11+, CI tests on 3.11 and 3.12
- `fs7.py` - French Stream - No `pyproject.toml` — uses `requirements.txt` with exact version pinning
- `GEMINI.md` and `CLAUDE.md` exist with tool-specific instructions (merge if conflicting)
3. **Video Players** (`app/downloaders/video_players/`) - French-language project (animes, séries, VOSTFR) but all code and comments in English
- `sibnet.py`, `doodstream.py`, `vidmoly.py`, `uqload.py` - ~20 empty `except:` blocks in downloaders/tests — known tech debt
- `uptobox.py`, `unfichier.py`, `rapidfile.py` - `JWT_SECRET_KEY` min 32 chars, default rejected at startup; generate via `Settings.generate_secret()`
- `sendvid.py`, `lpayer.py`, `vidzy.py`, `luluv.py` - Sub-AGENTS.md files exist in `app/`, `app/routers/`, `app/downloaders/`, `app/models/`,
- `oneupload.py`, `smoothpre.py` `app/downloaders/anime_sites/`, `app/downloaders/video_players/` for deeper context
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/
```
+47
View File
@@ -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`
+55 -43
View File
@@ -32,7 +32,8 @@ class UserManager:
def get_user(self, username: str) -> Optional[UserTable]: def get_user(self, username: str) -> Optional[UserTable]:
"""Get user by username""" """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: with Session(engine) as session:
statement = select(UserTable).where(UserTable.username == username) statement = select(UserTable).where(UserTable.username == username)
return session.exec(statement).first() return session.exec(statement).first()
@@ -44,7 +45,11 @@ class UserManager:
return session.exec(statement).first() return session.exec(statement).first()
def create_user( 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: ) -> UserTable:
"""Create a new user""" """Create a new user"""
with Session(engine) as session: with Session(engine) as session:
@@ -68,7 +73,7 @@ class UserManager:
full_name=full_name, full_name=full_name,
hashed_password=hashed_password, hashed_password=hashed_password,
is_active=True, is_active=True,
created_at=datetime.now() created_at=datetime.now(),
) )
session.add(user) session.add(user)
@@ -105,11 +110,11 @@ class UserManager:
db_user = session.get(UserTable, user_id) db_user = session.get(UserTable, user_id)
if not db_user: if not db_user:
return None return None
for key, value in update_data.items(): for key, value in update_data.items():
if hasattr(db_user, key): if hasattr(db_user, key):
setattr(db_user, key, value) setattr(db_user, key, value)
session.add(db_user) session.add(db_user)
session.commit() session.commit()
session.refresh(db_user) session.refresh(db_user)
@@ -191,9 +196,10 @@ REFRESH_TOKENS_FILE = "config/refresh_tokens.json"
def _load_refresh_tokens() -> Dict[str, dict]: def _load_refresh_tokens() -> Dict[str, dict]:
"""Load refresh tokens from file""" """Load refresh tokens from file"""
import json import json
try: try:
if os.path.exists(REFRESH_TOKENS_FILE): 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) return json.load(f)
except Exception as e: except Exception as e:
logger.error(f"Error loading refresh tokens: {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]): def _save_refresh_tokens(tokens: Dict[str, dict]):
"""Save refresh tokens to file""" """Save refresh tokens to file"""
import json import json
try: try:
os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True) 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) json.dump(tokens, f, indent=2, ensure_ascii=False, default=str)
except Exception as e: except Exception as e:
logger.error(f"Error saving refresh tokens: {e}") logger.error(f"Error saving refresh tokens: {e}")
@@ -216,59 +223,60 @@ def _get_jwt_config() -> dict:
"SECRET_KEY": settings.jwt_secret_key, "SECRET_KEY": settings.jwt_secret_key,
"ALGORITHM": settings.jwt_algorithm, "ALGORITHM": settings.jwt_algorithm,
"ACCESS_TOKEN_EXPIRE_MINUTES": settings.access_token_expire_minutes, "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]: def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
""" """
Create both access and refresh tokens. Create both access and refresh tokens.
Access token: short-lived (24 hours by default) Access token: short-lived (24 hours by default)
Refresh token: long-lived (30 days by default) Refresh token: long-lived (30 days by default)
Returns: (access_token, refresh_token) Returns: (access_token, refresh_token)
""" """
from jose import jwt from jose import jwt
import secrets import secrets
jwt_config = _get_jwt_config() jwt_config = _get_jwt_config()
# Create access token (short-lived) # 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 = data.copy()
access_data.update({"exp": access_expire, "type": "access"}) access_data.update({"exp": access_expire, "type": "access"})
access_token = jwt.encode( access_token = jwt.encode(
access_data, access_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
jwt_config["SECRET_KEY"],
algorithm=jwt_config["ALGORITHM"]
) )
# Create refresh token (long-lived) # 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 # Generate a unique token ID
token_id = secrets.token_urlsafe(32) token_id = secrets.token_urlsafe(32)
refresh_data = { refresh_data = {
"sub": data["sub"], "sub": data["sub"],
"token_id": token_id, "token_id": token_id,
"exp": refresh_expire, "exp": refresh_expire,
"type": "refresh" "type": "refresh",
} }
refresh_token = jwt.encode( refresh_token = jwt.encode(
refresh_data, refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
jwt_config["SECRET_KEY"],
algorithm=jwt_config["ALGORITHM"]
) )
# Store refresh token mapping # Store refresh token mapping
refresh_tokens = _load_refresh_tokens() refresh_tokens = _load_refresh_tokens()
refresh_tokens[token_id] = { refresh_tokens[token_id] = {
"username": data["sub"], "username": data["sub"],
"token_id": token_id, "token_id": token_id,
"created_at": datetime.now().isoformat(), "created_at": datetime.now().isoformat(),
"expires_at": refresh_expire.isoformat() "expires_at": refresh_expire.isoformat(),
} }
_save_refresh_tokens(refresh_tokens) _save_refresh_tokens(refresh_tokens)
return access_token, refresh_token return access_token, refresh_token
@@ -279,35 +287,37 @@ def verify_refresh_token(token: str) -> Optional[str]:
""" """
from jose import jwt from jose import jwt
from jose.exceptions import JWTError from jose.exceptions import JWTError
jwt_config = _get_jwt_config() jwt_config = _get_jwt_config()
try: 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 # Verify this is a refresh token
if payload.get("type") != "refresh": if payload.get("type") != "refresh":
return None return None
username = payload.get("sub") username = payload.get("sub")
token_id = payload.get("token_id") token_id = payload.get("token_id")
if not username or not token_id: if not username or not token_id:
return None return None
# Check if token exists in storage # Check if token exists in storage
refresh_tokens = _load_refresh_tokens() refresh_tokens = _load_refresh_tokens()
stored_token = refresh_tokens.get(token_id) stored_token = refresh_tokens.get(token_id)
if not stored_token: if not stored_token:
return None return None
# Verify token hasn't been revoked or expired # Verify token hasn't been revoked or expired
if stored_token.get("revoked"): if stored_token.get("revoked"):
return None return None
return username return username
except JWTError: except JWTError:
return None return None
@@ -319,24 +329,26 @@ def revoke_refresh_token(token: str) -> bool:
""" """
from jose import jwt from jose import jwt
from jose.exceptions import JWTError from jose.exceptions import JWTError
jwt_config = _get_jwt_config() jwt_config = _get_jwt_config()
try: 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") token_id = payload.get("token_id")
if not token_id: if not token_id:
return False return False
refresh_tokens = _load_refresh_tokens() refresh_tokens = _load_refresh_tokens()
if token_id in refresh_tokens: if token_id in refresh_tokens:
refresh_tokens[token_id]["revoked"] = True refresh_tokens[token_id]["revoked"] = True
refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat() refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat()
_save_refresh_tokens(refresh_tokens) _save_refresh_tokens(refresh_tokens)
return True return True
return False return False
except JWTError: except JWTError:
return False return False
+49
View File
@@ -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
+23 -23
View File
@@ -1,4 +1,4 @@
# Anime Sites Downloaders # Anime Sites (app/downloaders/anime_sites/)
## OVERVIEW ## OVERVIEW
Handlers for French anime streaming catalogs that provide metadata and episode listings, delegating actual video extraction to video player handlers. 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 | | File | Purpose |
|------|---------| |------|---------|
| `base.py` | Abstract `BaseAnimeSite` class defining the interface all anime sites implement | | `base.py` | Abstract `BaseAnimeSite` class defining the interface |
| `animesama.py` | Primary provider with dynamic domain switching, multiple video player extraction | | `animesama.py` | Primary provider dynamic domain switching, multiple video player extraction |
| `nekosama.py` | Neko-Sama / Gupy integration (metadata-only, no direct downloads) | | `nekosama.py` | Neko-Sama / Gupy integration (metadata-only, no direct downloads) |
| `animeultime.py` | Anime-Ultime catalog handler | | `animeultime.py` | Anime-Ultime catalog handler |
| `vostfree.py` | Vostfree catalog handler | | `vostfree.py` | Vostfree catalog handler |
@@ -16,26 +16,26 @@ Handlers for French anime streaming catalogs that provide metadata and episode l
## CONVENTIONS ## CONVENTIONS
### Interface Contract **Interface contract** — each site implements from `BaseAnimeSite`:
Each site must implement four async methods from `BaseAnimeSite`: - `can_handle(url)` — URL pattern matching
- `can_handle(url: str) -> bool` — URL pattern matching - `search_anime(query, lang)``[{title, url, cover_image}]`
- `search_anime(query, lang) -> list[dict]` — Returns `{title, url, cover_image}` - `get_episodes(anime_url, lang)``[{episode_number, url, title, host}]`
- `get_episodes(anime_url, lang) -> list[dict]` — Returns `{episode_number, url, title, host}` - `get_anime_metadata(anime_url)``{synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status}`
- `get_anime_metadata(anime_url) -> dict` — Returns `{synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status}` - `get_download_link(url)``(video_player_url, filename)`
- `get_download_link(url) -> tuple[str, str]` — Returns `(video_player_url, filename)`
### Key Patterns **Key patterns**:
- **Pipe-separated URLs**: `video_url|anime_page_url|episode_title` — preserves context across extraction - Pipe-separated URLs: `video_url|anime_page_url|episode_title`
- **Language parameter**: `lang="vostfr"` or `"vf"` — controls which episodes to return - Language param: `lang="vostfr"` or `"vf"`
- **Video player delegation**: Anime sites return player URLs (vidmoly, sendvid, sibnet, lpayer), not direct downloads - Video player delegation: returns player URLs (vidmoly, sendvid, etc.), NOT direct downloads
- **Filename generation**: `{anime_name} - S{season} - {episode}.mp4` format - Filename format: `{anime_name} - S{season} - {episode}.mp4`
- **HTTP headers**: Browser UA and referer required to avoid blocking - Browser UA + referer headers required
### Domain Detection **Domain detection**: `AnimeSamaDownloader` fetches current domain from `anime-sama.pw` dynamically. Uses fallback chain for video extraction.
- `AnimeSamaDownloader` fetches current domain from `anime-sama.pw` dynamically
- Uses fallback chain for video extraction: detected player → cached player → priority list
### Error Handling **Error handling**: Raise `Exception` with descriptive message. Log at `debug` for expected failures, `error` for unexpected. Validate URLs with `_test_video_url()` before returning.
- Raise `Exception` with descriptive message on failure
- Log at appropriate level (`debug` for expected failures, `error` for unexpected) ## ANTI-PATTERNS
- Validate extracted URLs with `_test_video_url()` before returning
- 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
File diff suppressed because it is too large Load Diff
+147 -104
View File
@@ -10,6 +10,10 @@ class AnimeUltimeDownloader(BaseAnimeSite):
BASE_DOMAINS = ["anime-ultime.com", "anime-ultime.net", "www.anime-ultime.net"] 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: def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS) return any(domain in url.lower() for domain in self.BASE_DOMAINS)
@@ -24,58 +28,79 @@ class AnimeUltimeDownloader(BaseAnimeSite):
final_url = str(response.url) final_url = str(response.url)
# Parse the page # 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) # Method 0: Look for og:video meta tag (most reliable for anime-ultime)
og_video = soup.find('meta', property='og:video') og_video = soup.find("meta", property="og:video")
if og_video and og_video.get('content'): if og_video and og_video.get("content"):
video_url = og_video['content'] video_url = og_video["content"]
if video_url.endswith('.mp4'): if video_url.endswith(".mp4"):
filename = self._generate_filename(final_url) filename = self._generate_filename(final_url)
print(f"[ANIME-ULTIME] Found og:video link: {video_url}") print(f"[ANIME-ULTIME] Found og:video link: {video_url}")
return video_url, filename return video_url, filename
# Method 1: Look for direct download links (DDL) # Method 1: Look for direct download links (DDL)
# Anime-Ultime often uses links to file hosts # 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: for link in download_links:
href = link['href'] href = link["href"]
text = link.get_text().lower() text = link.get_text().lower()
# Look for download buttons/links # 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 # 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) filename = self._generate_filename(final_url)
return href, filename return href, filename
# Method 2: Look for iframe with video player # Method 2: Look for iframe with video player
iframes = soup.find_all('iframe') iframes = soup.find_all("iframe")
for iframe in iframes: for iframe in iframes:
src = iframe.get('src', '') src = iframe.get("src", "")
if src and any(provider in src for provider in ['video', 'player', 'stream', 'play']): if src and any(
if src.startswith('http'): provider in src
for provider in ["video", "player", "stream", "play"]
):
if src.startswith("http"):
filename = self._generate_filename(final_url) filename = self._generate_filename(final_url)
return src, filename return src, filename
# Method 3: Look for video tags # Method 3: Look for video tags
videos = soup.find_all('video') videos = soup.find_all("video")
for video in videos: for video in videos:
src = video.get('src', '') src = video.get("src", "")
if src: if src:
filename = self._generate_filename(final_url) filename = self._generate_filename(final_url)
return src, filename return src, filename
# Check source tags # Check source tags
sources = video.find_all('source') sources = video.find_all("source")
for source in sources: for source in sources:
src = source.get('src', '') src = source.get("src", "")
if src: if src:
filename = self._generate_filename(final_url) filename = self._generate_filename(final_url)
return src, filename return src, filename
# Method 4: Look in scripts for video URLs # Method 4: Look in scripts for video URLs
scripts = soup.find_all('script') scripts = soup.find_all("script")
for script in scripts: for script in scripts:
if script.string: if script.string:
# Look for common video patterns # Look for common video patterns
@@ -91,26 +116,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
matches = re.findall(pattern, script.string) matches = re.findall(pattern, script.string)
for match in matches: for match in matches:
# Clean up escaped characters # Clean up escaped characters
match = match.replace('\\/', '/').replace('\\', '') match = match.replace("\\/", "/").replace("\\", "")
if any(ext in match for ext in ['mp4', 'm3u8', 'mkv']): if any(ext in match for ext in ["mp4", "m3u8", "mkv"]):
filename = self._generate_filename(final_url) filename = self._generate_filename(final_url)
return match, filename return match, filename
# Look for anime-ultime specific patterns # Look for anime-ultime specific patterns
# They sometimes store links in JavaScript variables # 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: if ddl_match:
ddl_url = ddl_match.group(1) ddl_url = ddl_match.group(1)
if ddl_url.startswith('http'): if ddl_url.startswith("http"):
filename = self._generate_filename(final_url) filename = self._generate_filename(final_url)
return ddl_url, filename return ddl_url, filename
# Method 5: Look for links with specific classes or IDs # Method 5: Look for links with specific classes or IDs
# Anime-Ultime might use specific class names for download links # 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: for link in potential_links:
href = link.get('href', '') href = link.get("href", "")
if href and href.startswith('http'): if href and href.startswith("http"):
filename = self._generate_filename(final_url) filename = self._generate_filename(final_url)
return href, filename return href, filename
@@ -132,36 +161,38 @@ class AnimeUltimeDownloader(BaseAnimeSite):
episode = "01" episode = "01"
# Format: info-0-1/EPISODE_ID or info-0-1/EPISODE_ID/NAME-EP-vostfr # 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 # 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: if ep_match:
ep_id = ep_match.group(1) ep_id = ep_match.group(1)
# Try to get anime name from URL path # 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: if name_match:
raw_name = name_match.group(1) raw_name = name_match.group(1)
# Extract episode number # 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: if ep_num_match:
episode = ep_num_match.group(1).zfill(2) episode = ep_num_match.group(1).zfill(2)
# Remove episode number and suffix from name # 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: else:
# Just use the ID # Just use the ID
anime_name = f"Episode {ep_id}" anime_name = f"Episode {ep_id}"
else: else:
anime_name = f"Episode {ep_id}" 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 # 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: if file_match:
anime_name = file_match.group(1).replace('-', ' ') anime_name = file_match.group(1).replace("-", " ")
# Sanitize filename # Sanitize filename
anime_name = anime_name.replace('/', ' ').strip() anime_name = anime_name.replace("/", " ").strip()
filename = f"{anime_name} - Episode {episode}.mp4" filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title() return filename.title()
@@ -173,30 +204,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
try: try:
print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}") print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url) response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml') soup = BeautifulSoup(response.text, "lxml")
metadata = { metadata = {
'synopsis': None, "synopsis": None,
'genres': [], "genres": [],
'rating': None, "rating": None,
'release_year': None, "release_year": None,
'studio': None, "studio": None,
'poster_image': None, "poster_image": None,
'banner_image': None, "banner_image": None,
'total_episodes': None, "total_episodes": None,
'status': None, "status": None,
'alternative_titles': [] "alternative_titles": [],
} }
# Extract synopsis # Extract synopsis
synopsis_selectors = [ synopsis_selectors = [
'div.synopsis', "div.synopsis",
'div.description', "div.description",
'div[class*="synopsis"]', 'div[class*="synopsis"]',
'div[class*="synopsis"]', 'div[class*="synopsis"]',
'p.synopsis', "p.synopsis",
'.info', ".info",
'div.texte' "div.texte",
] ]
for selector in synopsis_selectors: for selector in synopsis_selectors:
@@ -204,68 +235,73 @@ class AnimeUltimeDownloader(BaseAnimeSite):
if synopsis_elem: if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True) synopsis = synopsis_elem.get_text(strip=True)
if len(synopsis) > 50: if len(synopsis) > 50:
metadata['synopsis'] = synopsis metadata["synopsis"] = synopsis
break break
# Extract genres from meta tags and page content # Extract genres from meta tags and page content
page_text = soup.get_text() page_text = soup.get_text()
# Look for genre in meta tags # 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: if genre_meta:
genres_text = genre_meta.get('content', '') genres_text = genre_meta.get("content", "")
if genres_text: 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 # 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: if genre_links:
for link in genre_links[:5]: for link in genre_links[:5]:
genre = link.get_text(strip=True) genre = link.get_text(strip=True)
if genre and genre not in metadata['genres']: if genre and genre not in metadata["genres"]:
metadata['genres'].append(genre) metadata["genres"].append(genre)
# Extract rating # Extract rating
rating_selectors = [ rating_selectors = [
'span.rating', "span.rating",
'div.rating', "div.rating",
'span.score', "span.score",
'div.note', "div.note",
'.rating' ".rating",
] ]
for selector in rating_selectors: for selector in rating_selectors:
rating_elem = soup.select_one(selector) rating_elem = soup.select_one(selector)
if rating_elem: if rating_elem:
rating_text = rating_elem.get_text(strip=True) 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: if rating_match:
metadata['rating'] = f"{rating_match.group(1)}/10" metadata["rating"] = f"{rating_match.group(1)}/10"
break 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: if rating_match:
rating_val = float(rating_match.group(1)) * 2 rating_val = float(rating_match.group(1)) * 2
metadata['rating'] = f"{rating_val:.1f}/10" metadata["rating"] = f"{rating_val:.1f}/10"
break break
# Extract release year # 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: if year_match:
import datetime import datetime
current_year = datetime.datetime.now().year + 2 current_year = datetime.datetime.now().year + 2
year = int(year_match.group(1)) year = int(year_match.group(1))
if 1950 <= year <= current_year: if 1950 <= year <= current_year:
metadata['release_year'] = year metadata["release_year"] = year
# Extract poster image from og:image # 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: if og_image:
metadata['poster_image'] = og_image.get('content') metadata["poster_image"] = og_image.get("content")
# Extract total episodes # Extract total episodes
episodes_count = len(await self.get_episodes(anime_url)) episodes_count = len(await self.get_episodes(anime_url))
if episodes_count > 0: if episodes_count > 0:
metadata['total_episodes'] = episodes_count metadata["total_episodes"] = episodes_count
print(f"[ANIME-ULTIME] Extracted metadata: {metadata}") print(f"[ANIME-ULTIME] Extracted metadata: {metadata}")
return metadata return metadata
@@ -274,7 +310,9 @@ class AnimeUltimeDownloader(BaseAnimeSite):
print(f"[ANIME-ULTIME] Error extracting metadata: {e}") print(f"[ANIME-ULTIME] Error extracting metadata: {e}")
return {} 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 Search for anime on anime-ultime
Returns list of anime with title, url, and cover image Returns list of anime with title, url, and cover image
@@ -286,27 +324,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
""" """
try: try:
import time import time
start = time.time() start = time.time()
print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...") print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...")
# Anime-Ultime uses POST for search # Anime-Ultime uses POST for search
search_url = "https://www.anime-ultime.net/search-0-1" search_url = "https://www.anime-ultime.net/search-0-1"
response = await self.client.post(search_url, data={'search': query}) response = await self.client.post(search_url, data={"search": query})
soup = BeautifulSoup(response.text, 'lxml') soup = BeautifulSoup(response.text, "lxml")
elapsed = time.time() - start 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 = [] results = []
# Look for search result links - better parsing # Look for search result links - better parsing
# Search results use file-0-1/ pattern, not info- # 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() seen_urls = set()
for result in search_results[:10]: # Limit to 10 results for result in search_results[:10]: # Limit to 10 results
href = result.get('href', '') href = result.get("href", "")
raw_title = result.get_text().strip() raw_title = result.get_text().strip()
# Skip if no href # Skip if no href
@@ -322,40 +363,44 @@ class AnimeUltimeDownloader(BaseAnimeSite):
better_title = raw_title better_title = raw_title
# If raw_title is just "Télécharger" or similar, try to find better 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) # 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: 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 still no good title, look at parent/row elements
if len(better_title) < 5: if len(better_title) < 5:
# Check parent row (table structure) # Check parent row (table structure)
row = result.find_parent(['tr', 'td', 'div']) row = result.find_parent(["tr", "td", "div"])
if row: if row:
# Look for text in the row that's not the link text # Look for text in the row that's not the link text
row_text = row.get_text().strip() row_text = row.get_text().strip()
# Remove the link text from row text # Remove the link text from row text
if raw_title in 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: if len(row_text) > 5 and len(row_text) < 100:
better_title = row_text better_title = row_text
# Make URL absolute # Make URL absolute
if not href.startswith('http'): if not href.startswith("http"):
href = urljoin("https://www.anime-ultime.net/", href) href = urljoin("https://www.anime-ultime.net/", href)
result_item = { result_item = {
'title': better_title, "title": better_title,
'url': href, "url": href,
'type': 'search_result', "type": "search_result",
'metadata': None "metadata": None,
} }
# Fetch metadata if requested # Fetch metadata if requested
if include_metadata: if include_metadata:
metadata = await self.get_anime_metadata(href) metadata = await self.get_anime_metadata(href)
result_item['metadata'] = metadata result_item["metadata"] = metadata
results.append(result_item) results.append(result_item)
@@ -373,27 +418,27 @@ class AnimeUltimeDownloader(BaseAnimeSite):
""" """
try: try:
response = await self.client.get(anime_url) response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml') soup = BeautifulSoup(response.text, "lxml")
episodes = [] episodes = []
# Look for episode links - anime-ultime uses info-XXXXX-Name-XX-vostfr format # 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 # 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: for link in episode_links:
href = link.get('href', '') href = link.get("href", "")
text = link.get_text().strip() text = link.get_text().strip()
# Extract episode number from URL pattern # Extract episode number from URL pattern
# Matches: info-0-1/30200/Naruto-OAV-01-vostfr # 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: if not match:
# Try other patterns # 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: if not match:
# Try to extract from text # Try to extract from text
match = re.search(r'(\d+)', text) match = re.search(r"(\d+)", text)
if match: if match:
episode_num = match.group(1).zfill(2) # Pad with zero 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 # Extract the episode ID from href and build correct URL
# href might be "info-0-1/30200" or "info-0-1/30200/..." # href might be "info-0-1/30200" or "info-0-1/30200/..."
# We need: https://www.anime-ultime.net/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: if ep_id_match:
ep_id = ep_id_match.group(1) ep_id = ep_id_match.group(1)
# Build the correct episode URL # Build the correct episode URL
episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}" episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}"
else: else:
# Fallback to making URL absolute # Fallback to making URL absolute
if not href.startswith('http'): if not href.startswith("http"):
href = urljoin(anime_url, href) href = urljoin(anime_url, href)
episode_url = href episode_url = href
episodes.append({ episodes.append(
'episode': episode_num, {"episode": episode_num, "url": episode_url, "title": text}
'url': episode_url, )
'title': text
})
# Remove duplicates and sort # Remove duplicates and sort
seen = set() seen = set()
unique_episodes = [] unique_episodes = []
for ep in episodes: for ep in episodes:
if ep['episode'] not in seen: if ep["episode"] not in seen:
seen.add(ep['episode']) seen.add(ep["episode"])
unique_episodes.append(ep) 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 return unique_episodes
+90 -82
View File
@@ -1,4 +1,5 @@
"""French-Manga.net anime streaming site downloader""" """French-Manga.net anime streaming site downloader"""
from .base import BaseAnimeSite from .base import BaseAnimeSite
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import re import re
@@ -17,11 +18,12 @@ class FrenchMangaDownloader(BaseAnimeSite):
"french-manga.net", "french-manga.net",
"w16.french-manga.net", "w16.french-manga.net",
"w15.french-manga.net", "w15.french-manga.net",
"www.french-manga.net" "www.french-manga.net",
] ]
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.id = "french-manga"
self.base_url = "https://w16.french-manga.net" self.base_url = "https://w16.french-manga.net"
def can_handle(self, url: str) -> bool: 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) return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def search_anime( async def search_anime(
self, self, query: str, lang: str = "vostfr"
query: str,
lang: str = "vostfr"
) -> List[Dict[str, str]]: ) -> List[Dict[str, str]]:
""" """
Search for anime on French-Manga. Search for anime on French-Manga.
@@ -47,46 +47,50 @@ class FrenchMangaDownloader(BaseAnimeSite):
# French-Manga uses a search endpoint # French-Manga uses a search endpoint
search_url = f"{self.base_url}/index.php?do=search" search_url = f"{self.base_url}/index.php?do=search"
params = { params = {
'do': 'search', "do": "search",
'subaction': 'search', "subaction": "search",
'story': query, "story": query,
'x': '0', "x": "0",
'y': '0' "y": "0",
} }
response = await self.client.post(search_url, data=params) response = await self.client.post(search_url, data=params)
response.raise_for_status() response.raise_for_status()
html = response.text html = response.text
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, "lxml")
results = [] results = []
# Look for search results in article or story classes # 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()): for item in soup.find_all(
title_elem = item.find(['h2', 'h3', 'h4']) "article", class_=lambda x: x and "story" in x.lower()
link_elem = item.find('a', href=True) ):
img_elem = item.find('img') 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: if title_elem and link_elem:
title = title_elem.get_text(strip=True) title = title_elem.get_text(strip=True)
url = link_elem['href'] url = link_elem["href"]
# Ensure absolute URL # Ensure absolute URL
if url.startswith('/'): if url.startswith("/"):
url = self.base_url + url url = self.base_url + url
cover_image = "" cover_image = ""
if img_elem and img_elem.get('src'): if img_elem and img_elem.get("src"):
cover_image = img_elem['src'] cover_image = img_elem["src"]
if cover_image.startswith('/'): if cover_image.startswith("/"):
cover_image = self.base_url + cover_image cover_image = self.base_url + cover_image
results.append({ results.append(
'title': title, {
'url': url, "title": title,
'cover_image': cover_image, "url": url,
'lang': lang "cover_image": cover_image,
}) "lang": lang,
}
)
logger.info(f"Found {len(results)} anime results for query: {query}") logger.info(f"Found {len(results)} anime results for query: {query}")
return results return results
@@ -96,9 +100,7 @@ class FrenchMangaDownloader(BaseAnimeSite):
return [] return []
async def get_episodes( async def get_episodes(
self, self, anime_url: str, lang: str = "vostfr"
anime_url: str,
lang: str = "vostfr"
) -> List[Dict[str, str]]: ) -> List[Dict[str, str]]:
""" """
Get episode list for an anime. Get episode list for an anime.
@@ -115,34 +117,36 @@ class FrenchMangaDownloader(BaseAnimeSite):
response.raise_for_status() response.raise_for_status()
html = response.text html = response.text
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, "lxml")
episodes = [] episodes = []
# Look for episode links (typically in a list or table) # Look for episode links (typically in a list or table)
# French-Manga usually has episode links in <a> tags with episode numbers # French-Manga usually has episode links in <a> tags with episode numbers
for link in soup.find_all('a', href=True): for link in soup.find_all("a", href=True):
href = link['href'] href = link["href"]
text = link.get_text(strip=True) text = link.get_text(strip=True)
# Pattern: Episode links usually contain "episode" or numbers # Pattern: Episode links usually contain "episode" or numbers
if re.search(r'episode?\s*\d+', text.lower()): if re.search(r"episode?\s*\d+", text.lower()):
episode_num = re.search(r'(\d+)', text) episode_num = re.search(r"(\d+)", text)
if episode_num: if episode_num:
episode_number = int(episode_num.group(1)) episode_number = int(episode_num.group(1))
# Ensure absolute URL # Ensure absolute URL
if href.startswith('/'): if href.startswith("/"):
href = self.base_url + href href = self.base_url + href
episodes.append({ episodes.append(
'episode_number': episode_number, {
'url': href, "episode_number": episode_number,
'title': text, "url": href,
'host': 'french-manga' "title": text,
}) "host": "french-manga",
}
)
# Sort by episode number # 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}") logger.info(f"Found {len(episodes)} episodes for {anime_url}")
return episodes return episodes
@@ -166,31 +170,33 @@ class FrenchMangaDownloader(BaseAnimeSite):
response.raise_for_status() response.raise_for_status()
html = response.text html = response.text
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, "lxml")
# Extract title # Extract title
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: if title_elem:
title = title_elem.get_text(strip=True) title = title_elem.get_text(strip=True)
# Extract synopsis # Extract synopsis
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: if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True) synopsis = synopsis_elem.get_text(strip=True)
# Extract cover image # Extract cover image
poster_image = "" poster_image = ""
img_elem = soup.find('img', class_=lambda x: x and 'poster' in x.lower()) img_elem = soup.find("img", class_=lambda x: x and "poster" in x.lower())
if img_elem and img_elem.get('src'): if img_elem and img_elem.get("src"):
poster_image = img_elem['src'] poster_image = img_elem["src"]
if poster_image.startswith('/'): if poster_image.startswith("/"):
poster_image = self.base_url + poster_image poster_image = self.base_url + poster_image
# Extract genres # Extract genres
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 for link in genre_links[:10]: # Limit to 10 genres
genre = link.get_text(strip=True) genre = link.get_text(strip=True)
if genre: if genre:
@@ -198,36 +204,38 @@ class FrenchMangaDownloader(BaseAnimeSite):
# Extract rating (if available) # Extract rating (if available)
rating = "" 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: if rating_elem:
rating = rating_elem.get_text(strip=True) rating = rating_elem.get_text(strip=True)
return { return {
'title': title, "title": title,
'synopsis': synopsis, "synopsis": synopsis,
'genres': genres, "genres": genres,
'rating': rating, "rating": rating,
'release_year': '', "release_year": "",
'studio': '', "studio": "",
'poster_image': poster_image, "poster_image": poster_image,
'total_episodes': len(await self.get_episodes(anime_url)), "total_episodes": len(await self.get_episodes(anime_url)),
'status': '', "status": "",
'languages': ['vf', 'vostfr'] "languages": ["vf", "vostfr"],
} }
except Exception as e: except Exception as e:
logger.error(f"Error getting anime metadata: {e}") logger.error(f"Error getting anime metadata: {e}")
return { return {
'title': '', "title": "",
'synopsis': '', "synopsis": "",
'genres': [], "genres": [],
'rating': '', "rating": "",
'release_year': '', "release_year": "",
'studio': '', "studio": "",
'poster_image': '', "poster_image": "",
'total_episodes': 0, "total_episodes": 0,
'status': '', "status": "",
'languages': ['vf', 'vostfr'] "languages": ["vf", "vostfr"],
} }
async def get_download_link(self, url: str) -> tuple[str, str]: async def get_download_link(self, url: str) -> tuple[str, str]:
@@ -248,20 +256,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
response.raise_for_status() response.raise_for_status()
html = response.text html = response.text
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, "lxml")
# Look for iframe or video player # Look for iframe or video player
iframe = soup.find('iframe', src=True) iframe = soup.find("iframe", src=True)
if iframe: if iframe:
video_url = iframe['src'] video_url = iframe["src"]
else: else:
# Look for video tag directly # Look for video tag directly
video = soup.find('video', src=True) video = soup.find("video", src=True)
if video: if video:
video_url = video['src'] video_url = video["src"]
else: else:
# Try to find in script tags # Try to find in script tags
scripts = soup.find_all('script') scripts = soup.find_all("script")
for script in scripts: for script in scripts:
if script.string: if script.string:
# Look for iframe or video URLs in JavaScript # Look for iframe or video URLs in JavaScript
@@ -274,20 +282,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
if match: if match:
video_url = match.group(1) video_url = match.group(1)
break break
if 'video_url' in locals(): if "video_url" in locals():
break break
if 'video_url' not in locals(): if "video_url" not in locals():
raise ValueError("Could not find video player URL") raise ValueError("Could not find video player URL")
# Ensure absolute URL # Ensure absolute URL
if video_url.startswith('//'): if video_url.startswith("//"):
video_url = 'https:' + video_url video_url = "https:" + video_url
elif video_url.startswith('/'): elif video_url.startswith("/"):
video_url = self.base_url + video_url video_url = self.base_url + video_url
# Extract episode title # 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 = title_elem.get_text(strip=True) if title_elem else "Episode"
episode_title = sanitize_filename(episode_title) episode_title = sanitize_filename(episode_title)
+119 -87
View File
@@ -7,79 +7,100 @@ from urllib.parse import urljoin
class NekoSamaDownloader(BaseAnimeSite): class NekoSamaDownloader(BaseAnimeSite):
"""Downloader for neko-sama.org (anime streaming via Gupy) """Downloader for neko-sama.org (anime streaming via Gupy)
NOTE: neko-sama.org now redirects to Gupy, which is a legal streaming search engine. 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. 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. 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: def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS) 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. Extract download link from neko-sama URL.
NOTE: neko-sama.org/Gupy is a legal streaming search engine, NOT a video host. 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. This returns streaming platform information instead of direct video links.
""" """
try: try:
# Check if this is a Gupy URL # 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) response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, 'lxml') soup = BeautifulSoup(response.text, "lxml")
# Look for streaming platform links # Look for streaming platform links
streaming_links = [] streaming_links = []
for link in soup.find_all('a', href=True): for link in soup.find_all("a", href=True):
href = link.get('href', '') href = link.get("href", "")
if '/out/' in href: if "/out/" in href:
text = link.get_text(strip=True) text = link.get_text(strip=True)
if text and 'Regarder' in text: if text and "Regarder" in text:
streaming_links.append(f"{text}: {href}") streaming_links.append(f"{text}: {href}")
if streaming_links: if streaming_links:
title_elem = soup.find('h1') or soup.find('title') title_elem = soup.find("h1") or soup.find("title")
title = title_elem.get_text(strip=True).split('|')[0].strip() if title_elem else "Unknown" title = (
info = "Available streaming platforms:\n" + "\n".join(streaming_links[:5]) 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" filename = target_filename or f"{title}_streaming_info.txt"
return info, filename 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 # Legacy: try original method for other URLs
response = await self.client.get(url, follow_redirects=True) 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 # Method 1: Look for iframes with video
iframes = soup.find_all('iframe') iframes = soup.find_all("iframe")
for iframe in iframes: for iframe in iframes:
src = iframe.get('src', '') src = iframe.get("src", "")
if src and any(p in src for p in ['video', 'player', 'stream']): if src and any(p in src for p in ["video", "player", "stream"]):
if not src.startswith('http'): if not src.startswith("http"):
src = urljoin(str(response.url), src) src = urljoin(str(response.url), src)
filename = self._generate_filename(str(response.url)) filename = self._generate_filename(str(response.url))
return src, filename return src, filename
# Method 2: Look for video tags # Method 2: Look for video tags
videos = soup.find_all('video') videos = soup.find_all("video")
for video in videos: for video in videos:
src = video.get('src') or video.get('data-src') src = video.get("src") or video.get("data-src")
if src: if src:
filename = self._generate_filename(str(response.url)) filename = self._generate_filename(str(response.url))
return src, filename return src, filename
sources = video.find_all('source') sources = video.find_all("source")
for source in sources: for source in sources:
src = source.get('src', '') src = source.get("src", "")
if src: if src:
filename = self._generate_filename(str(response.url)) filename = self._generate_filename(str(response.url))
return src, filename return src, filename
# Method 3: Look in scripts # Method 3: Look in scripts
scripts = soup.find_all('script') scripts = soup.find_all("script")
for script in scripts: for script in scripts:
if script.string: if script.string:
patterns = [ patterns = [
@@ -90,24 +111,26 @@ class NekoSamaDownloader(BaseAnimeSite):
for pattern in patterns: for pattern in patterns:
matches = re.findall(pattern, script.string) matches = re.findall(pattern, script.string)
for match in matches: for match in matches:
match = match.replace('\\/', '/') match = match.replace("\\/", "/")
if any(ext in match for ext in ['mp4', 'm3u8']): if any(ext in match for ext in ["mp4", "m3u8"]):
filename = self._generate_filename(str(response.url)) filename = self._generate_filename(str(response.url))
return match, filename 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: except Exception as e:
raise Exception(f"Error extracting NekoSama link: {str(e)}") raise Exception(f"Error extracting NekoSama link: {str(e)}")
def _generate_filename(self, url: str) -> str: def _generate_filename(self, url: str) -> str:
parts = url.split('/') parts = url.split("/")
anime_name = "anime" anime_name = "anime"
episode = "1" episode = "1"
for i, part in enumerate(parts): for i, part in enumerate(parts):
if 'episode' in part.lower(): if "episode" in part.lower():
match = re.search(r'episode[-\s]*(\d+)', part, re.I) match = re.search(r"episode[-\s]*(\d+)", part, re.I)
if match: if match:
episode = match.group(1) episode = match.group(1)
@@ -118,31 +141,31 @@ class NekoSamaDownloader(BaseAnimeSite):
"""Get list of episodes for an anime.""" """Get list of episodes for an anime."""
try: try:
response = await self.client.get(anime_url) response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml') soup = BeautifulSoup(response.text, "lxml")
episodes = [] episodes = []
# Try to find episode links # 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: for link in episode_links:
href = link.get('href', '') href = link.get("href", "")
match = re.search(r'episode[-\s]*(\d+)', href, re.I) match = re.search(r"episode[-\s]*(\d+)", href, re.I)
if match: if match:
episode_num = match.group(1) episode_num = match.group(1)
if not href.startswith('http'): if not href.startswith("http"):
href = urljoin(anime_url, href) href = urljoin(anime_url, href)
episodes.append({'episode': episode_num, 'url': href}) episodes.append({"episode": episode_num, "url": href})
# Deduplicate and sort # Deduplicate and sort
seen = set() seen = set()
unique_episodes = [] unique_episodes = []
for ep in episodes: for ep in episodes:
if ep['episode'] not in seen: if ep["episode"] not in seen:
seen.add(ep['episode']) seen.add(ep["episode"])
unique_episodes.append(ep) 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 return unique_episodes
except Exception as e: except Exception as e:
@@ -153,70 +176,70 @@ class NekoSamaDownloader(BaseAnimeSite):
try: try:
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}") print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url) response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml') soup = BeautifulSoup(response.text, "lxml")
metadata = { metadata = {
'synopsis': None, "synopsis": None,
'genres': [], "genres": [],
'rating': None, "rating": None,
'release_year': None, "release_year": None,
'studio': None, "studio": None,
'poster_image': None, "poster_image": None,
'banner_image': None, "banner_image": None,
'total_episodes': None, "total_episodes": None,
'status': None, "status": None,
'alternative_titles': [] "alternative_titles": [],
} }
# Extract title and year from h1 # Extract title and year from h1
title_elem = soup.find('h1') title_elem = soup.find("h1")
if title_elem: if title_elem:
title_text = title_elem.get_text(strip=True) title_text = title_elem.get_text(strip=True)
# Extract year from title like "Naruto (2002)" # 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: 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 # Extract synopsis - Gupy shows it as paragraphs
synopsis_elem = soup.find('p') synopsis_elem = soup.find("p")
if synopsis_elem: if synopsis_elem:
text = synopsis_elem.get_text(strip=True) text = synopsis_elem.get_text(strip=True)
if len(text) > 50: if len(text) > 50:
metadata['synopsis'] = text metadata["synopsis"] = text
# Extract genres from meta tags or links # 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: if genre_links:
genres = [] genres = []
for link in genre_links[:5]: for link in genre_links[:5]:
text = link.get_text(strip=True) 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) genres.append(text)
metadata['genres'] = genres metadata["genres"] = genres
# Extract rating from percentage # 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: if rating_elem:
match = re.search(r'(\d+(\.\d+)?)%', rating_elem) match = re.search(r"(\d+(\.\d+)?)%", rating_elem)
if match: if match:
rating = float(match.group(1)) / 10 rating = float(match.group(1)) / 10
metadata['rating'] = f"{rating:.1f}/10" metadata["rating"] = f"{rating:.1f}/10"
# Extract poster image # 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: if poster_elem:
metadata['poster_image'] = poster_elem.get('src') metadata["poster_image"] = poster_elem.get("src")
# Extract episode count from page text # Extract episode count from page text
page_text = soup.get_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: if ep_match:
metadata['total_episodes'] = int(ep_match.group(1)) metadata["total_episodes"] = int(ep_match.group(1))
# Extract studio/director # 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: 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}") print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
return metadata return metadata
@@ -225,16 +248,19 @@ class NekoSamaDownloader(BaseAnimeSite):
print(f"[NEKO-SAMA] Error extracting metadata: {e}") print(f"[NEKO-SAMA] Error extracting metadata: {e}")
return {} 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).""" """Search for anime on neko-sama (uses Gupy backend)."""
try: try:
import time import time
from html import unescape from html import unescape
start = time.time() start = time.time()
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...") print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
# Neko-Sama now uses Gupy - try the direct URL pattern # Neko-Sama now uses Gupy - try the direct URL pattern
search_slug = query.lower().replace(' ', '-') search_slug = query.lower().replace(" ", "-")
search_urls = [ search_urls = [
f"https://www.gupy.fr/series/{search_slug}/", f"https://www.gupy.fr/series/{search_slug}/",
f"https://neko-sama.org/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}") print(f"[NEKO-SAMA] Found anime at {final_url}")
# Extract title from page # Extract title from page
soup = BeautifulSoup(response.text, 'lxml') soup = BeautifulSoup(response.text, "lxml")
title_elem = soup.find('h1') or soup.find('title') title_elem = soup.find("h1") or soup.find("title")
title = unescape(title_elem.get_text(strip=True)) if title_elem else query title = (
unescape(title_elem.get_text(strip=True))
if title_elem
else query
)
# Clean up title # Clean up title
title = title.split('|')[0].split('-')[0].strip() title = title.split("|")[0].split("-")[0].strip()
result = { result = {
'title': title, "title": title,
'url': final_url, "url": final_url,
'cover_image': None, "cover_image": None,
'type': 'direct', "type": "direct",
'metadata': None "metadata": None,
} }
# Try to get poster # Try to get poster
poster = soup.find('img', src=re.compile(r'poster')) poster = soup.find("img", src=re.compile(r"poster"))
if poster: if poster:
result['cover_image'] = poster.get('src') result["cover_image"] = poster.get("src")
if include_metadata: if include_metadata:
metadata = await self.get_anime_metadata(final_url) metadata = await self.get_anime_metadata(final_url)
result['metadata'] = metadata result["metadata"] = metadata
results.append(result) results.append(result)
break break
elapsed = time.time() - start 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 return results
except Exception as e: except Exception as e:
+78 -63
View File
@@ -9,6 +9,10 @@ class VostfreeDownloader(BaseAnimeSite):
BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"] BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"]
def __init__(self):
super().__init__()
self.id = "vostfree"
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS) 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""" """Extract download link from vostfree URL"""
try: try:
response = await self.client.get(url, follow_redirects=True) 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 # Method 1: Look for iframe players
iframes = soup.find_all('iframe') iframes = soup.find_all("iframe")
for iframe in iframes: for iframe in iframes:
src = iframe.get('src', '') src = iframe.get("src", "")
if src and any(p in src for p in ['player', 'video', 'stream']): if src and any(p in src for p in ["player", "video", "stream"]):
if not src.startswith('http'): if not src.startswith("http"):
src = urljoin(str(response.url), src) src = urljoin(str(response.url), src)
filename = self._generate_filename(str(response.url)) filename = self._generate_filename(str(response.url))
return src, filename return src, filename
# Method 2: Look for video tags # Method 2: Look for video tags
videos = soup.find_all('video') videos = soup.find_all("video")
for video in videos: for video in videos:
src = video.get('src') src = video.get("src")
if src: if src:
filename = self._generate_filename(str(response.url)) filename = self._generate_filename(str(response.url))
return src, filename return src, filename
sources = video.find_all('source') sources = video.find_all("source")
for source in sources: for source in sources:
src = source.get('src', '') src = source.get("src", "")
if src and any(ext in src for ext in ['mp4', 'm3u8']): if src and any(ext in src for ext in ["mp4", "m3u8"]):
filename = self._generate_filename(str(response.url)) filename = self._generate_filename(str(response.url))
return src, filename return src, filename
# Method 3: Look in scripts # Method 3: Look in scripts
scripts = soup.find_all('script') scripts = soup.find_all("script")
for script in scripts: for script in scripts:
if script.string: if script.string:
patterns = [ patterns = [
@@ -56,8 +60,8 @@ class VostfreeDownloader(BaseAnimeSite):
for pattern in patterns: for pattern in patterns:
matches = re.findall(pattern, script.string) matches = re.findall(pattern, script.string)
for match in matches: for match in matches:
match = match.replace('\\/', '/') match = match.replace("\\/", "/")
if any(ext in match for ext in ['mp4', 'm3u8']): if any(ext in match for ext in ["mp4", "m3u8"]):
filename = self._generate_filename(str(response.url)) filename = self._generate_filename(str(response.url))
return match, filename return match, filename
@@ -67,12 +71,12 @@ class VostfreeDownloader(BaseAnimeSite):
raise Exception(f"Error extracting Vostfree link: {str(e)}") raise Exception(f"Error extracting Vostfree link: {str(e)}")
def _generate_filename(self, url: str) -> str: def _generate_filename(self, url: str) -> str:
parts = url.split('/') parts = url.split("/")
anime_name = "anime" anime_name = "anime"
episode = "1" episode = "1"
for part in parts: 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: if match:
episode = match.group(1) episode = match.group(1)
@@ -82,30 +86,30 @@ class VostfreeDownloader(BaseAnimeSite):
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]: async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
try: try:
response = await self.client.get(anime_url) response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml') soup = BeautifulSoup(response.text, "lxml")
episodes = [] 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: for link in episode_links:
href = link.get('href', '') href = link.get("href", "")
match = re.search(r'episode[-\s]*(\d+)', href, re.I) match = re.search(r"episode[-\s]*(\d+)", href, re.I)
if match: if match:
episode_num = match.group(1) episode_num = match.group(1)
if not href.startswith('http'): if not href.startswith("http"):
href = urljoin(anime_url, href) href = urljoin(anime_url, href)
episodes.append({'episode': episode_num, 'url': href}) episodes.append({"episode": episode_num, "url": href})
# Deduplicate and sort # Deduplicate and sort
seen = set() seen = set()
unique_episodes = [] unique_episodes = []
for ep in episodes: for ep in episodes:
if ep['episode'] not in seen: if ep["episode"] not in seen:
seen.add(ep['episode']) seen.add(ep["episode"])
unique_episodes.append(ep) 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 return unique_episodes
except Exception as e: except Exception as e:
@@ -119,29 +123,29 @@ class VostfreeDownloader(BaseAnimeSite):
try: try:
print(f"[VOSTFREE] Extracting metadata from: {anime_url}") print(f"[VOSTFREE] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url) response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml') soup = BeautifulSoup(response.text, "lxml")
metadata = { metadata = {
'synopsis': None, "synopsis": None,
'genres': [], "genres": [],
'rating': None, "rating": None,
'release_year': None, "release_year": None,
'studio': None, "studio": None,
'poster_image': None, "poster_image": None,
'banner_image': None, "banner_image": None,
'total_episodes': None, "total_episodes": None,
'status': None, "status": None,
'alternative_titles': [] "alternative_titles": [],
} }
# Extract synopsis # Extract synopsis
synopsis_selectors = [ synopsis_selectors = [
'div.synopsis', "div.synopsis",
'div.description', "div.description",
'div[class*="synopsis"]', 'div[class*="synopsis"]',
'div[class*="desc"]', 'div[class*="desc"]',
'p.synopsis', "p.synopsis",
'.anime-synopsis' ".anime-synopsis",
] ]
for selector in synopsis_selectors: for selector in synopsis_selectors:
@@ -149,57 +153,65 @@ class VostfreeDownloader(BaseAnimeSite):
if synopsis_elem: if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True) synopsis = synopsis_elem.get_text(strip=True)
if len(synopsis) > 50: if len(synopsis) > 50:
metadata['synopsis'] = synopsis metadata["synopsis"] = synopsis
break break
# Extract genres # 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: 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 # Extract rating
rating_selectors = [ rating_selectors = [
'span.rating', "span.rating",
'div.rating', "div.rating",
'span.score', "span.score",
'div[class*="rating"]', 'div[class*="rating"]',
'div[class*="score"]' 'div[class*="score"]',
] ]
for selector in rating_selectors: for selector in rating_selectors:
rating_elem = soup.select_one(selector) rating_elem = soup.select_one(selector)
if rating_elem: if rating_elem:
rating_text = rating_elem.get_text(strip=True) 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: if rating_match:
metadata['rating'] = f"{rating_match.group(1)}/10" metadata["rating"] = f"{rating_match.group(1)}/10"
break break
# Extract release year # Extract release year
page_text = soup.get_text() 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: if year_matches:
import datetime import datetime
current_year = datetime.datetime.now().year + 2 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: if valid_years:
from collections import Counter 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 # 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: 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 # Extract poster from og:image
og_image = soup.find('meta', property='og:image') og_image = soup.find("meta", property="og:image")
if og_image and not metadata['poster_image']: if og_image and not metadata["poster_image"]:
metadata['poster_image'] = og_image.get('content') metadata["poster_image"] = og_image.get("content")
# Extract total episodes # Extract total episodes
episodes_count = len(await self.get_episodes(anime_url)) episodes_count = len(await self.get_episodes(anime_url))
if episodes_count > 0: if episodes_count > 0:
metadata['total_episodes'] = episodes_count metadata["total_episodes"] = episodes_count
print(f"[VOSTFREE] Extracted metadata: {metadata}") print(f"[VOSTFREE] Extracted metadata: {metadata}")
return metadata return metadata
@@ -208,7 +220,9 @@ class VostfreeDownloader(BaseAnimeSite):
print(f"[VOSTFREE] Error extracting metadata: {e}") print(f"[VOSTFREE] Error extracting metadata: {e}")
return {} 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 Search for anime on vostfree
@@ -219,6 +233,7 @@ class VostfreeDownloader(BaseAnimeSite):
""" """
try: try:
import time import time
start = time.time() start = time.time()
print(f"[VOSTFREE] Searching for '{query}' ({lang})...") print(f"[VOSTFREE] Searching for '{query}' ({lang})...")
@@ -233,15 +248,15 @@ class VostfreeDownloader(BaseAnimeSite):
if response.status_code == 200: if response.status_code == 200:
print(f"[VOSTFREE] Found anime at {str(response.url)}") print(f"[VOSTFREE] Found anime at {str(response.url)}")
result = { result = {
'title': query, "title": query,
'url': str(response.url), "url": str(response.url),
'type': 'direct', "type": "direct",
'metadata': None "metadata": None,
} }
if include_metadata: if include_metadata:
metadata = await self.get_anime_metadata(str(response.url)) metadata = await self.get_anime_metadata(str(response.url))
result['metadata'] = metadata result["metadata"] = metadata
return [result] return [result]
+128 -139
View File
@@ -1,4 +1,5 @@
"""FS7 (French Stream) series site downloader""" """FS7 (French Stream) series site downloader"""
import logging import logging
import re import re
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
@@ -19,29 +20,46 @@ class FS7Downloader(BaseSeriesSite):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.base_url = "https://fs7.lol" self.id = "fs7"
self.search_url = f"{self.base_url}/" self.provider_id = "fs7"
# Update client headers to mimic browser self.default_domain = "fs7.lol"
self.client.headers.update({ self.test_tlds = ["lol", "com", "net", "org", "tv", "ws", "cc", "co"]
'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', self.base_url = f"https://{self.default_domain}"
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', self._domain_checked = False
'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', self.client.headers.update(
'Accept-Encoding': 'gzip, deflate', {
'Connection': 'keep-alive', "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",
'Upgrade-Insecure-Requests': '1' "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: def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL""" """Check if this downloader can handle the given URL"""
return "fs7.lol" in url.lower() or "french-stream" in url.lower() return "fs7.lol" in url.lower() or "french-stream" in url.lower()
async def search_anime( async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
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: Args:
query: Search query query: Search query
@@ -51,91 +69,61 @@ class FS7Downloader(BaseSeriesSite):
List of series with title, url, cover_image List of series with title, url, cover_image
""" """
try: try:
await self._ensure_base_url()
logger.info(f"Searching FS7 for: {query}") logger.info(f"Searching FS7 for: {query}")
# FS7 uses GET request with query parameters for search ajax_url = f"{self.base_url}/engine/ajax/search.php"
response = await self.client.get( response = await self.client.post(
self.search_url, ajax_url,
params={ data={"query": query, "page": "1"},
"do": "search", headers={
"subaction": "search", "Content-Type": "application/x-www-form-urlencoded",
"story": query "X-Requested-With": "XMLHttpRequest",
} "Referer": f"{self.base_url}/",
},
) )
response.raise_for_status() response.raise_for_status()
html = response.text html = response.text
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, "lxml")
results = [] results = []
# Look for series items for item in soup.find_all("div", class_="search-item")[:24]:
# FS7 usually structure: <div class="movie-item">...<a href="..."><img src="..."></a>...</div> onclick = item.get("onclick", "")
# Or directly <a> tags with images url_match = re.search(r"location\.href=['\"]([^'\"]+)['\"]", onclick)
items = soup.find_all('div', class_='movie-item') if not url_match:
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:
continue continue
url = url_match.group(1)
url = link_elem.get('href', '') if not url.startswith("http"):
if not url.startswith('http'):
url = urljoin(self.base_url, url) url = urljoin(self.base_url, url)
# Extract title title_elem = item.find("div", class_="search-title")
img_elem = item.find('img') title = title_elem.get_text(strip=True) if title_elem else ""
title = "" title = re.sub(r"\s+", " ", title).strip()
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)
# Extract cover image
img_elem = item.find('img')
cover_image = "" cover_image = ""
if img_elem: poster_elem = item.find("div", class_="search-poster")
# Check for common lazy loading attributes used by various themes if poster_elem:
cover_image = ( img = poster_elem.find("img")
img_elem.get('data-src') or if img:
img_elem.get('data-original') or cover_image = (
img_elem.get('src') or img.get("data-src")
"" or img.get("data-original")
) or img.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)
if title and len(title) > 2: if title and len(title) > 2:
if not any(r['url'] == url for r in results): results.append(
results.append({ {
'title': title, "title": title,
'url': url, "url": url,
'cover_image': cover_image "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 return results
except Exception as e: except Exception as e:
@@ -143,9 +131,7 @@ class FS7Downloader(BaseSeriesSite):
return [] return []
async def get_episodes( async def get_episodes(
self, self, anime_url: str, lang: str = "vf"
anime_url: str,
lang: str = "vf"
) -> List[Dict[str, str]]: ) -> List[Dict[str, str]]:
""" """
Get episode list for a series. Get episode list for a series.
@@ -164,31 +150,33 @@ class FS7Downloader(BaseSeriesSite):
response.raise_for_status() response.raise_for_status()
html = response.text html = response.text
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, "lxml")
episodes = [] episodes = []
# Get series title for episode naming # 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" series_title = title_elem.get_text(strip=True) if title_elem else "Series"
# Clean up title: remove "affiche" suffix # 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 # FS7 stores episode data in JavaScript div elements
# Format: <div data-ep="1" data-vidzy="..." data-uqload="..." data-netu="..." data-voe="..."></div> # Format: <div data-ep="1" data-vidzy="..." data-uqload="..." data-netu="..." data-voe="..."></div>
episode_divs = soup.find_all('div', attrs={'data-ep': True}) episode_divs = soup.find_all("div", attrs={"data-ep": True})
for div in episode_divs: 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 # Try different video players in order of preference
video_url = None video_url = None
host_name = None host_name = None
for player in ['data-vidzy', 'data-uqload', 'data-voe', 'data-netu']: for player in ["data-vidzy", "data-uqload", "data-voe", "data-netu"]:
player_url = div.get(player, '').strip() player_url = div.get(player, "").strip()
if player_url: if player_url:
video_url = player_url video_url = player_url
# Extract host name from attribute name # 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}") logger.debug(f"Found episode {ep_num} on {host_name}")
break break
@@ -199,15 +187,19 @@ class FS7Downloader(BaseSeriesSite):
# Use pipe-separated format: video_url|anime_url|episode_title # Use pipe-separated format: video_url|anime_url|episode_title
combined_url = f"{video_url}|{anime_url}|{episode_title}" combined_url = f"{video_url}|{anime_url}|{episode_title}"
episodes.append({ episodes.append(
'episode': ep_num, {
'url': combined_url, "episode": ep_num,
'title': episode_title, "url": combined_url,
'host': host_name or 'Unknown' "title": episode_title,
}) "host": host_name or "Unknown",
}
)
# Sort by episode number # 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") logger.info(f"Found {len(episodes)} episodes")
return episodes return episodes
@@ -216,10 +208,7 @@ class FS7Downloader(BaseSeriesSite):
logger.error(f"Error getting episodes from FS7: {e}") logger.error(f"Error getting episodes from FS7: {e}")
return [] return []
async def get_anime_metadata( async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
self,
anime_url: str
) -> Dict[str, Any]:
""" """
Get metadata for a series. Get metadata for a series.
@@ -236,62 +225,62 @@ class FS7Downloader(BaseSeriesSite):
response.raise_for_status() response.raise_for_status()
html = response.text html = response.text
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, "lxml")
# Extract title # Extract title
title = soup.find('h1') title = soup.find("h1")
title = title.get_text(strip=True) if title else "Unknown" title = title.get_text(strip=True) if title else "Unknown"
# Clean up title: remove "affiche" suffix # 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 # Extract description/synopsis
description_elem = soup.find('div', class_='full-text') description_elem = soup.find("div", class_="full-text")
description = description_elem.get_text(strip=True) if description_elem else "" description = (
description_elem.get_text(strip=True) if description_elem else ""
)
# Extract cover image # Extract cover image
img = soup.find('img', class_='poster') img = soup.find("img", class_="poster")
poster_image = img.get('src', '') if img else '' poster_image = img.get("src", "") if img else ""
# Try to get poster from meta tag if not found # Try to get poster from meta tag if not found
if not poster_image: if not poster_image:
meta_img = soup.find('meta', property='og:image') meta_img = soup.find("meta", property="og:image")
poster_image = meta_img.get('content', '') if meta_img else '' poster_image = meta_img.get("content", "") if meta_img else ""
# Extract year # 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 release_year = int(year_match.group()) if year_match else None
return { return {
'title': title, "title": title,
'synopsis': description, "synopsis": description,
'poster_image': poster_image, "poster_image": poster_image,
'release_year': release_year, "release_year": release_year,
'genres': [], "genres": [],
'rating': None, "rating": None,
'studio': None, "studio": None,
'total_episodes': None, "total_episodes": None,
'status': None "status": None,
} }
except Exception as e: except Exception as e:
logger.error(f"Error getting metadata from FS7: {e}") logger.error(f"Error getting metadata from FS7: {e}")
return { return {
'title': "Unknown", "title": "Unknown",
'synopsis': "", "synopsis": "",
'poster_image': '', "poster_image": "",
'genres': [], "genres": [],
'rating': None, "rating": None,
'release_year': None, "release_year": None,
'studio': None, "studio": None,
'total_episodes': None, "total_episodes": None,
'status': None "status": None,
} }
async def get_download_link( async def get_download_link(
self, self, url: str, target_filename: Optional[str] = None
url: str,
target_filename: Optional[str] = None
) -> tuple[str, str]: ) -> tuple[str, str]:
""" """
Extract download link from video player URL. Extract download link from video player URL.
+127 -104
View File
@@ -1,4 +1,5 @@
"""Zone-Telechargement series site downloader""" """Zone-Telechargement series site downloader"""
import logging import logging
import re import re
from typing import List, Dict, Any, Optional, Tuple from typing import List, Dict, Any, Optional, Tuple
@@ -18,94 +19,106 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.id = "zonetelechargement"
self.provider_id = "zonetelechargement" self.provider_id = "zonetelechargement"
self.default_domain = "zone-telechargement.cam" self.default_domain = "zone-telechargement.golf"
self.test_tlds = ["cam", "net", "org", "blue", "lol", "work"] self.test_tlds = ["golf", "cam", "net", "org", "blue", "lol", "work", "ws"]
self.base_url = None # Will be set dynamically 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', self.client.headers.update(
'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', "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): async def _ensure_base_url(self):
"""Ensure base_url is set to the current active domain""" """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( active_domain = await DomainManager.get_active_domain(
self.provider_id, self.provider_id, self.default_domain, self.test_tlds, test_path="/"
self.default_domain,
self.test_tlds,
test_path="/"
) )
self.base_url = f"https://{active_domain}" self.base_url = f"https://{active_domain}"
logger.info(f"Using active domain for Zone-Telechargement: {self.base_url}") 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: def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL""" """Check if this downloader can handle the given URL"""
return "zone-telechargement" in url.lower() or "zt-za" in url.lower() return "zone-telechargement" in url.lower() or "zt-za" in url.lower()
async def search_anime( async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
self, """Search for series on Zone-Telechargement.
query: str,
lang: str = "vf" ZT uses server-side rendered search: GET /?p=series&search=QUERY.
) -> List[Dict[str, str]]: Results are in div.cover_global containers with nested cover_infos_title links.
"""Search for series on Zone-Telechargement""" """
try: try:
await self._ensure_base_url() await self._ensure_base_url()
logger.info(f"Searching Zone-Telechargement for: {query}") logger.info(f"Searching Zone-Telechargement for: {query}")
# ZT uses POST or GET for search depending on the version search_url = f"{self.base_url}/"
# Most modern versions use: /index.php?do=search params = {"p": "series", "search": query}
search_url = f"{self.base_url}/index.php?do=search"
response = await self.client.get(search_url, params=params)
# 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)
response.raise_for_status() response.raise_for_status()
html = response.text html = response.text
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, "lxml")
results = [] results = []
# Look for items for cover_div in soup.find_all("div", class_="cover_global")[:24]:
items = soup.find_all('div', class_='shm-item') or soup.find_all('div', class_='movie-item') link_in_cover = cover_div.find("a", class_="mainimg")
if not link_in_cover:
for item in items[:24]: link_in_cover = cover_div.find("a")
link_elem = item.find('a', class_='shm-title') or item.find('a')
if not link_elem: if not link_in_cover:
continue continue
url = link_elem.get('href', '') url = link_in_cover.get("href", "")
if not url.startswith('http'): if not url.startswith("http"):
url = urljoin(self.base_url, url) url = urljoin(self.base_url, url)
title = link_elem.get_text(strip=True) img = cover_div.find("img")
img_elem = item.find('img')
cover_image = "" cover_image = ""
if img_elem: if img:
cover_image = img_elem.get('data-src') or img_elem.get('src') or "" cover_image = img.get("data-src") or img.get("src") or ""
if cover_image and not cover_image.startswith("http"):
if cover_image and not cover_image.startswith('http'): cover_image = urljoin(self.base_url, cover_image)
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: if title and len(title) > 2:
results.append({ results.append(
'title': title, {
'url': url, "title": title,
'cover_image': cover_image, "url": url,
'provider_id': self.provider_id "cover_image": cover_image,
}) "provider_id": self.provider_id,
}
)
logger.info(
f"Zone-Telechargement found {len(results)} results for '{query}'"
)
return results return results
except Exception as e: except Exception as e:
@@ -113,39 +126,35 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
return [] return []
async def get_episodes( async def get_episodes(
self, self, anime_url: str, lang: str = "vf"
anime_url: str,
lang: str = "vf"
) -> List[Dict[str, str]]: ) -> List[Dict[str, str]]:
"""Extract episodes from a series page""" """Extract episodes from a series page"""
try: try:
await self._ensure_base_url() await self._ensure_base_url()
html = await self._fetch_page(anime_url) html = await self._fetch_page(anime_url)
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, "lxml")
episodes = [] episodes = []
# ZT typically lists episodes in a table or list of links # ZT typically lists episodes in a table or list of links
# Links often look like: /telecharger-series/.../saison-X-episode-Y.html # 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): for i, link in enumerate(links):
href = link.get('href', '') href = link.get("href", "")
if not href.startswith('http'): if not href.startswith("http"):
href = urljoin(self.base_url, href) href = urljoin(self.base_url, href)
title = link.get_text(strip=True) 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 ep_number = int(ep_match.group(1)) if ep_match else i + 1
episodes.append({ episodes.append(
'episode_number': ep_number, {"episode_number": ep_number, "url": href, "title": title}
'url': href, )
'title': title
})
# Sort by episode number # Sort by episode number
episodes.sort(key=lambda x: x['episode_number']) episodes.sort(key=lambda x: x["episode_number"])
return episodes return episodes
except Exception as e: except Exception as e:
@@ -157,32 +166,40 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
try: try:
await self._ensure_base_url() await self._ensure_base_url()
html = await self._fetch_page(anime_url) html = await self._fetch_page(anime_url)
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, "lxml")
metadata = { metadata = {
'title': "", "title": "",
'synopsis': "", "synopsis": "",
'genres': [], "genres": [],
'poster_image': "", "poster_image": "",
'status': "Unknown" "status": "Unknown",
} }
title_elem = soup.find('h1') title_elem = soup.find("h1")
if title_elem: if title_elem:
metadata['title'] = title_elem.get_text(strip=True) metadata["title"] = title_elem.get_text(strip=True)
# Synopsis # 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: if syn_elem:
metadata['synopsis'] = syn_elem.get_text(strip=True) metadata["synopsis"] = syn_elem.get_text(strip=True)
# Poster # 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: 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 return metadata
except Exception as e: except Exception as e:
logger.error(f"Error getting metadata from Zone-Telechargement: {e}") logger.error(f"Error getting metadata from Zone-Telechargement: {e}")
return {} return {}
@@ -192,19 +209,25 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
try: try:
await self._ensure_base_url() await self._ensure_base_url()
html = await self._fetch_page(url) html = await self._fetch_page(url)
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, "lxml")
# Look for video player links (Uptobox, 1fichier, etc.) # Look for video player links (Uptobox, 1fichier, etc.)
# ZT often has multiple hosts # 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: if links:
player_url = links[0].get('href', '') player_url = links[0].get("href", "")
title = soup.find('h1').get_text(strip=True) if soup.find('h1') else "Episode" title = (
soup.find("h1").get_text(strip=True)
if soup.find("h1")
else "Episode"
)
return player_url, title return player_url, title
return "", "" return "", ""
except Exception as e: except Exception as e:
logger.error(f"Error getting download link from Zone-Telechargement: {e}") logger.error(f"Error getting download link from Zone-Telechargement: {e}")
return "", "" return "", ""
+28 -17
View File
@@ -1,16 +1,26 @@
# Video Players (app/downloaders/video_players) # Video Players (app/downloaders/video_players/)
## OVERVIEW ## 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 ## WHERE TO LOOK
| Need | File | | File | Purpose |
|------|------| |------|---------|
| Base class | `base.py` - `BaseVideoPlayer` abstract class | | `base.py` | `BaseVideoPlayer` abstract class |
| Add new player | Create new `.py` file, inherit `BaseVideoPlayer`, add to `__init__.py` | | `unfichier.py` | 1fichier.com |
| URL detection logic | Each player's `can_handle()` method | | `doodstream.py` | Doodstream |
| Extract download link | Each player's `get_download_link()` method | | `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 ## 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]: ... 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. **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 ## ANTI-PATTERNS
- Do NOT hardcode User-Agent in each player (use base class headers) - Do NOT hardcode User-Agent per player use base class headers
- Do NOT forget to call `await self.close()` after extraction - Do NOT forget `await self.close()` — resource leak
- Do NOT return None for missing URLs, raise an exception - Do NOT return None for missing URLs raise an exception
- Do NOT use sync `requests`, use async `httpx` - Do NOT use sync `requests` use async `httpx`
- Do NOT skip the `target_filename` parameter, even if unused - Do NOT skip `target_filename` parameter — required for anime/series site compatibility
- 8 empty `except:` blocks across players — known tech debt
+45
View File
@@ -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
+81 -29
View File
@@ -3,56 +3,94 @@
ANIME_PROVIDERS = { ANIME_PROVIDERS = {
"anime-sama": { "anime-sama": {
"name": "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}/", "url_pattern": "https://anime-sama.to/catalogue/{anime}/saison{season}/{lang}/",
"icon": "🎬", "icon": "🎬",
"color": "#00d9ff" "color": "#00d9ff",
}, },
"anime-ultime": { "anime-ultime": {
"name": "Anime-Ultime", "name": "Anime-Ultime",
"domains": ["anime-ultime.net", "anime-ultime.com", "www.anime-ultime.net"], "domains": ["anime-ultime.net", "anime-ultime.com", "www.anime-ultime.net"],
"url_pattern": "https://www.anime-ultime.net/info-{id}-{slug}", "url_pattern": "https://www.anime-ultime.net/info-{id}-{slug}",
"icon": "▶️", "icon": "▶️",
"color": "#00ff88" "color": "#00ff88",
}, },
"neko-sama": { "neko-sama": {
"name": "Neko-Sama", "name": "Neko-Sama",
"domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"], "domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"],
"url_pattern": "https://neko-sama.fr/anime/{slug}", "url_pattern": "https://neko-sama.fr/anime/{slug}",
"icon": "🐱", "icon": "🐱",
"color": "#ff6b6b" "color": "#ff6b6b",
}, },
"vostfree": { "vostfree": {
"name": "Vostfree", "name": "Vostfree",
"domains": ["vostfree.tv", "www.vostfree.tv"], "domains": ["vostfree.tv", "www.vostfree.tv"],
"url_pattern": "https://vostfree.tv/anime/{slug}", "url_pattern": "https://vostfree.tv/anime/{slug}",
"icon": "📺", "icon": "📺",
"color": "#ffd93d" "color": "#ffd93d",
}, },
"french-manga": { "french-manga": {
"name": "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", "url_pattern": "https://w16.french-manga.net/{slug}.html",
"icon": "🇫🇷", "icon": "🇫🇷",
"color": "#ff7675" "color": "#ff7675",
} },
} }
SERIES_PROVIDERS = { SERIES_PROVIDERS = {
"fs7": { "fs7": {
"name": "French Stream", "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", "url_pattern": "https://fs7.lol/s-tv/{slug}.html",
"icon": "🎬", "icon": "🎬",
"color": "#ff6b9d" "color": "#ff6b9d",
}, },
"zonetelechargement": { "zonetelechargement": {
"name": "Zone-Telechargement", "name": "Zone-Telechargement",
"domains": ["zone-telechargement.cam", "zone-telechargement.net", "zone-telechargement.org", "zone-telechargement.blue", "zone-telechargement.lol", "zone-telechargement.work"], "domains": [
"url_pattern": "https://zone-telechargement.cam/index.php?do=search", "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": "⬇️", "icon": "⬇️",
"color": "#00d9ff" "color": "#00d9ff",
} },
} }
FILE_HOSTS = { FILE_HOSTS = {
@@ -60,98 +98,112 @@ FILE_HOSTS = {
"name": "1fichier", "name": "1fichier",
"domains": ["1fichier.com", "1fichier.fr"], "domains": ["1fichier.com", "1fichier.fr"],
"icon": "📁", "icon": "📁",
"color": "#4ecdc4" "color": "#4ecdc4",
}, },
"uptobox": { "uptobox": {
"name": "Uptobox", "name": "Uptobox",
"domains": ["uptobox.com", "uptobox.fr"], "domains": ["uptobox.com", "uptobox.fr"],
"icon": "📦", "icon": "📦",
"color": "#45b7d1" "color": "#45b7d1",
}, },
"doodstream": { "doodstream": {
"name": "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": "🎥", "icon": "🎥",
"color": "#f7b731" "color": "#f7b731",
}, },
"rapidfile": { "rapidfile": {
"name": "Rapidfile", "name": "Rapidfile",
"domains": ["rapidfile.net", "rapidfile.com"], "domains": ["rapidfile.net", "rapidfile.com"],
"icon": "", "icon": "",
"color": "#ff6b6b" "color": "#ff6b6b",
}, },
"vidmoly": { "vidmoly": {
"name": "VidMoly", "name": "VidMoly",
"domains": ["vidmoly.to", "vidmoly.org", "vidmoly.biz"], "domains": ["vidmoly.to", "vidmoly.org", "vidmoly.biz"],
"icon": "🎬", "icon": "🎬",
"color": "#a29bfe" "color": "#a29bfe",
}, },
"sendvid": { "sendvid": {
"name": "SendVid", "name": "SendVid",
"domains": ["sendvid.com", "sendvid.io"], "domains": ["sendvid.com", "sendvid.io"],
"icon": "📤", "icon": "📤",
"color": "#fd79a8" "color": "#fd79a8",
}, },
"sibnet": { "sibnet": {
"name": "Sibnet", "name": "Sibnet",
"domains": ["sibnet.ru", "video.sibnet.ru"], "domains": ["sibnet.ru", "video.sibnet.ru"],
"icon": "🎞️", "icon": "🎞️",
"color": "#00cec9" "color": "#00cec9",
}, },
"lpayer": { "lpayer": {
"name": "Lplayer", "name": "Lplayer",
"domains": ["lpayer.embed4me.com", "lpayer.com", "lplayer.fr"], "domains": ["lpayer.embed4me.com", "lpayer.com", "lplayer.fr"],
"icon": "▶️", "icon": "▶️",
"color": "#e17055" "color": "#e17055",
}, },
"vidzy": { "vidzy": {
"name": "Vidzy", "name": "Vidzy",
"domains": ["vidzy.com", "vidzy.net", "www.vidzy.com"], "domains": ["vidzy.com", "vidzy.net", "www.vidzy.com"],
"icon": "🎞️", "icon": "🎞️",
"color": "#74b9ff" "color": "#74b9ff",
}, },
"luluv": { "luluv": {
"name": "LuLuvid", "name": "LuLuvid",
"domains": ["luluv.com", "luluvid.com", "www.luluv.com", "www.luluvid.com"], "domains": ["luluv.com", "luluvid.com", "www.luluv.com", "www.luluvid.com"],
"icon": "🎬", "icon": "🎬",
"color": "#a29bfe" "color": "#a29bfe",
}, },
"uqload": { "uqload": {
"name": "Uqload", "name": "Uqload",
"domains": ["uqload.bz", "uqload.com", "www.uqload.bz", "www.uqload.com"], "domains": ["uqload.bz", "uqload.com", "www.uqload.bz", "www.uqload.com"],
"icon": "📺", "icon": "📺",
"color": "#fd79a8" "color": "#fd79a8",
}, },
"smoothpre": { "smoothpre": {
"name": "Smoothpre", "name": "Smoothpre",
"domains": ["smoothpre.com", "www.smoothpre.com"], "domains": ["smoothpre.com", "www.smoothpre.com"],
"icon": "🎬", "icon": "🎬",
"color": "#a29bfe" "color": "#a29bfe",
} },
} }
def get_all_providers(): def get_all_providers():
"""Get all supported providers (anime + series + file hosts)""" """Get all supported providers (anime + series + file hosts)"""
return {**ANIME_PROVIDERS, **SERIES_PROVIDERS, **FILE_HOSTS} return {**ANIME_PROVIDERS, **SERIES_PROVIDERS, **FILE_HOSTS}
def get_anime_providers(): def get_anime_providers():
"""Get all anime streaming providers""" """Get all anime streaming providers"""
return ANIME_PROVIDERS return ANIME_PROVIDERS
def get_series_providers(): def get_series_providers():
"""Get all series streaming providers""" """Get all series streaming providers"""
return SERIES_PROVIDERS return SERIES_PROVIDERS
def get_file_hosts(): def get_file_hosts():
"""Get all file hosting providers""" """Get all file hosting providers"""
return FILE_HOSTS return FILE_HOSTS
def detect_provider_from_url(url: str) -> str | None: def detect_provider_from_url(url: str) -> str | None:
"""Detect which provider can handle the given URL""" """Detect which provider can handle the given URL"""
url_lower = url.lower() url_lower = url.lower()
for provider_id, provider in get_all_providers().items(): for provider_id, provider in get_all_providers().items():
for domain in provider['domains']: for domain in provider["domains"]:
if domain in url_lower: if domain in url_lower:
return provider_id return provider_id
+89 -13
View File
@@ -1,4 +1,5 @@
"""Manages scraper providers and their health status""" """Manages scraper providers and their health status"""
import os import os
import logging import logging
import asyncio import asyncio
@@ -7,6 +8,18 @@ from pathlib import Path
from datetime import datetime from datetime import datetime
from app.downloaders.generic_scraper import GenericScraper 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__) logger = logging.getLogger(__name__)
@@ -16,11 +29,13 @@ class ProvidersManager:
def __init__(self, config_dir: str = "app/downloaders/providers_config"): def __init__(self, config_dir: str = "app/downloaders/providers_config"):
self.config_dir = Path(config_dir) 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.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""" """Load all providers from YAML configs"""
if not self.config_dir.exists(): if not self.config_dir.exists():
logger.warning(f"Providers config directory not found: {self.config_dir}") logger.warning(f"Providers config directory not found: {self.config_dir}")
@@ -33,46 +48,107 @@ class ProvidersManager:
self.health_status[scraper.id] = { self.health_status[scraper.id] = {
"status": "unknown", "status": "unknown",
"last_check": None, "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: except Exception as e:
logger.error(f"Failed to load provider from {config_file}: {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): async def check_all_health(self):
"""Check health of all registered providers""" """Check health of all registered providers"""
logger.info("Checking health of all providers...") logger.info("Checking health of all providers...")
tasks = [] tasks = []
for provider_id, scraper in self.providers.items(): for provider_id, scraper in self.providers.items():
tasks.append(self._check_single_health(provider_id, scraper)) tasks.append(self._check_single_health(provider_id, scraper))
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
logger.info("Provider health check complete") 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""" """Check health of a single provider and update status"""
try: try:
is_healthy = await scraper.check_health() is_healthy = await self._do_health_check(scraper)
self.health_status[provider_id] = { self.health_status[provider_id] = {
"status": "up" if is_healthy else "down", "status": "up" if is_healthy else "down",
"last_check": datetime.now().isoformat(), "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: except Exception as e:
self.health_status[provider_id] = { self.health_status[provider_id] = {
"status": "down", "status": "down",
"last_check": datetime.now().isoformat(), "last_check": datetime.now().isoformat(),
"error": str(e) "error": str(e),
} }
logger.error(f"Health check failed for {provider_id}: {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) 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 only providers that are UP or UNKNOWN"""
return [ 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" if status["status"] != "down"
] ]
+37
View File
@@ -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
+91 -44
View File
@@ -9,7 +9,15 @@ import logging
import asyncio import asyncio
import hashlib 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 fastapi.templating import Jinja2Templates
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -35,10 +43,12 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["anime"]) router = APIRouter(prefix="/api", tags=["anime"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
# Add custom filters to Jinja2 # Add custom filters to Jinja2
def hash_filter(s): def hash_filter(s):
return hashlib.md5(s.encode()).hexdigest()[:10] return hashlib.md5(s.encode()).hexdigest()[:10]
templates.env.filters["hash"] = hash_filter templates.env.filters["hash"] = hash_filter
@@ -52,6 +62,7 @@ async def get_providers_health():
async def trigger_providers_health_check(background_tasks: BackgroundTasks): async def trigger_providers_health_check(background_tasks: BackgroundTasks):
"""Trigger a manual health check of all providers in the background""" """Trigger a manual health check of all providers in the background"""
from app.auto_download_scheduler import auto_download_scheduler from app.auto_download_scheduler import auto_download_scheduler
background_tasks.add_task(auto_download_scheduler.trigger_health_check_now) background_tasks.add_task(auto_download_scheduler.trigger_health_check_now)
return {"status": "Health check triggered in background"} 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: def get_download_manager() -> DownloadManager:
"""Get the download manager instance from main app""" """Get the download manager instance from main app"""
from main import download_manager from main import download_manager
return download_manager return download_manager
@@ -73,7 +85,7 @@ async def search_anime_unified(
include_metadata: bool = False, include_metadata: bool = False,
html: bool = Query(False), html: bool = Query(False),
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session) session: Session = Depends(get_session),
): ):
""" """
Search across all anime providers. Search across all anime providers.
@@ -83,12 +95,14 @@ async def search_anime_unified(
start_time = time.time() start_time = time.time()
# Get user settings for disabled providers # 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() settings_obj = session.exec(statement).first()
disabled_providers = settings_obj.disabled_providers if settings_obj else [] disabled_providers = settings_obj.disabled_providers if settings_obj else []
results = {} results = {}
# 1. Prepare search tasks (Generic + Legacy) # 1. Prepare search tasks (Generic + Legacy)
search_tasks = [] search_tasks = []
task_metadata = [] task_metadata = []
@@ -96,19 +110,26 @@ async def search_anime_unified(
# Generic YAML providers # Generic YAML providers
active_generic = providers_manager.get_active_providers() active_generic = providers_manager.get_active_providers()
for provider in active_generic: for provider in active_generic:
if provider.id not in disabled_providers: provider_id = getattr(provider, "id", None)
search_tasks.append(provider.search(q)) if provider_id and provider_id not in disabled_providers:
task_metadata.append({"id": provider.id, "type": "generic"}) 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 = { legacy_downloaders = {
"anime-ultime": AnimeUltimeDownloader(), "anime-ultime": AnimeUltimeDownloader(),
"neko-sama": NekoSamaDownloader(), "neko-sama": NekoSamaDownloader(),
"vostfree": VostfreeDownloader(), "vostfree": VostfreeDownloader(),
} }
for pid, dl in legacy_downloaders.items(): for pid, dl in legacy_downloaders.items():
if pid not in disabled_providers: if pid not in disabled_providers and pid not in {
search_tasks.append(dl.search_anime(q, lang, include_metadata=False)) getattr(p, "id", None) for p in active_generic
}:
search_tasks.append(dl.search_anime(q, lang))
task_metadata.append({"id": pid, "type": "legacy"}) task_metadata.append({"id": pid, "type": "legacy"})
# 2. Run searches in parallel # 2. Run searches in parallel
@@ -118,25 +139,25 @@ async def search_anime_unified(
seen_urls = set() seen_urls = set()
enricher = await get_metadata_enricher() enricher = await get_metadata_enricher()
enrichment_tasks = [] enrichment_tasks = []
enrichment_mapping = [] enrichment_mapping = []
for i, raw_result in enumerate(all_raw_results): for i, raw_result in enumerate(all_raw_results):
provider_info = task_metadata[i] provider_info = task_metadata[i]
pid = provider_info["id"] pid = provider_info["id"]
if isinstance(raw_result, Exception): if isinstance(raw_result, Exception):
logger.error(f"Search failed for {pid}: {raw_result}") logger.error(f"Search failed for {pid}: {raw_result}")
continue continue
if not raw_result: if not raw_result:
continue continue
if pid not in results: if pid not in results:
results[pid] = [] results[pid] = []
for item in raw_result: for item in raw_result:
item_dict = item.model_dump() if hasattr(item, "model_dump") else item item_dict = item.model_dump() if hasattr(item, "model_dump") else item
url = item_dict.get("url") url = item_dict.get("url")
if url and url not in seen_urls: if url and url not in seen_urls:
seen_urls.add(url) seen_urls.add(url)
if q.lower() in (item_dict.get("title") or "").lower(): if q.lower() in (item_dict.get("title") or "").lower():
@@ -144,10 +165,16 @@ async def search_anime_unified(
else: else:
item_dict["_relevance_boost"] = 0.5 item_dict["_relevance_boost"] = 0.5
results[pid].append(item_dict) results[pid].append(item_dict)
# Prepare enrichment task for top 5 results per provider # Prepare enrichment task for top 5 results per provider
if len(results[pid]) <= 5: 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)) enrichment_mapping.append((pid, len(results[pid]) - 1))
else: else:
if "metadata" not in item_dict: if "metadata" not in item_dict:
@@ -170,18 +197,16 @@ async def search_anime_unified(
elapsed = time.time() - start_time elapsed = time.time() - start_time
total_found = sum(len(r) for r in results.values()) 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 # 6. Return HTML for HTMX or JSON for API
if html or request.headers.get("HX-Request"): if html or request.headers.get("HX-Request"):
print("[SEARCH] Returning HTML response") print("[SEARCH] Returning HTML response")
return templates.TemplateResponse( return templates.TemplateResponse(
"components/anime_search_results.html", "components/anime_search_results.html",
{ {"request": request, "results": results, "settings": settings_obj},
"request": request,
"results": results,
"settings": settings_obj
}
) )
print("[SEARCH] Returning JSON response") print("[SEARCH] Returning JSON response")
@@ -200,7 +225,7 @@ async def search_series_unified(
lang: str = "vf", lang: str = "vf",
html: bool = Query(False), html: bool = Query(False),
current_user: User = Depends(get_current_user_from_token), 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.) Search across all TV series providers (FS7, etc.)
@@ -213,14 +238,16 @@ async def search_series_unified(
start_time = time.time() start_time = time.time()
# Get user settings for disabled providers # 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() settings_obj = session.exec(statement).first()
disabled_providers = settings_obj.disabled_providers if settings_obj else [] disabled_providers = settings_obj.disabled_providers if settings_obj else []
results = {} results = {}
series_downloaders = { series_downloaders = {
"fs7": FS7Downloader(), "fs7": FS7Downloader(),
"zonetelechargement": ZoneTelechargementDownloader() "zonetelechargement": ZoneTelechargementDownloader(),
} }
search_tasks = [] search_tasks = []
provider_ids = [] provider_ids = []
@@ -236,22 +263,24 @@ async def search_series_unified(
for provider_id, result in zip(provider_ids, search_results): for provider_id, result in zip(provider_ids, search_results):
if isinstance(result, Exception): if isinstance(result, Exception):
print(f"[SERIES SEARCH] {provider_id} error: {str(result)}") print(f"[SERIES SEARCH] {provider_id} error: {str(result)}")
logger.error(f"Series search error for {provider_id}: {result}")
elif result: elif result:
results[provider_id] = 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 elapsed = time.time() - start_time
total_found = sum(len(r) for r in results.values()) 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 # Return HTML for HTMX or JSON for API
if html or request.headers.get("HX-Request"): if html or request.headers.get("HX-Request"):
return templates.TemplateResponse( return templates.TemplateResponse(
"components/series_search_results.html", "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} 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) metadata = await downloader.get_anime_metadata(url)
return {"url": url, "metadata": metadata} return {"url": url, "metadata": metadata}
else: 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: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -284,7 +316,7 @@ async def get_anime_episodes(
""" """
downloader = get_downloader(url) downloader = get_downloader(url)
episodes = await downloader.get_episodes(url, lang) episodes = await downloader.get_episodes(url, lang)
if html or request.headers.get("HX-Request"): if html or request.headers.get("HX-Request"):
# Extract title from first episode or URL for the display # Extract title from first episode or URL for the display
anime_title = "Épisodes" anime_title = "Épisodes"
@@ -297,12 +329,12 @@ async def get_anime_episodes(
return templates.TemplateResponse( return templates.TemplateResponse(
"components/episode_list.html", "components/episode_list.html",
{ {
"request": request, "request": request,
"episodes": episodes, "episodes": episodes,
"anime_url": url, "anime_url": url,
"anime_title": anime_title, "anime_title": anime_title,
"lang": lang "lang": lang,
} },
) )
return {"url": url, "lang": lang, "episodes": episodes} return {"url": url, "lang": lang, "episodes": episodes}
@@ -329,10 +361,17 @@ async def download_anime_episode(
request = DownloadRequest(url=url) request = DownloadRequest(url=url)
task = download_manager.create_task(request) task = download_manager.create_task(request)
background_tasks.add_task(download_manager.start_download, task.id) background_tasks.add_task(download_manager.start_download, task.id)
# Add toast notification for HTMX # 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} 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""" """Search for anime on MyAnimeList and get full details"""
from app.recommendations import AnimeReleasesFetcher from app.recommendations import AnimeReleasesFetcher
fetcher = AnimeReleasesFetcher() fetcher = AnimeReleasesFetcher()
try: try:
search_results = await fetcher.search_anime(q, limit=limit) 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): async def translate_text(request: Request):
"""Translate text from English to French using Google Translate""" """Translate text from English to French using Google Translate"""
import httpx import httpx
try: try:
body = await request.json() body = await request.json()
text = body.get("text", "") text = body.get("text", "")
@@ -408,7 +449,13 @@ async def translate_text(request: Request):
raise HTTPException(status_code=400, detail="Text is required") raise HTTPException(status_code=400, detail="Text is required")
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
url = "https://translate.googleapis.com/translate_a/single" 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) response = await client.get(url, params=params)
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
+51 -19
View File
@@ -10,6 +10,7 @@ Endpoints:
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -39,15 +40,48 @@ async def get_current_user_from_token(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
user_dict = user_manager.get_user(username) user = user_manager.get_user(username)
if user_dict is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found", detail="User not found",
headers={"WWW-Authenticate": "Bearer"}, 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") @router.post("/register")
@@ -69,15 +103,13 @@ async def register(user_data: UserCreate):
) )
user_response = User( user_response = User(
id=user["id"], id=user.id,
username=user["username"], username=user.username,
email=user.get("email"), email=user.email,
full_name=user.get("full_name"), full_name=user.full_name,
is_active=user["is_active"], is_active=user.is_active,
created_at=datetime.fromisoformat(user["created_at"]), created_at=user.created_at,
last_login=datetime.fromisoformat(user["last_login"]) last_login=user.last_login,
if user.get("last_login")
else None,
) )
return { return {
@@ -111,23 +143,23 @@ async def login(form_data: UserLogin):
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if not user.get("is_active", True): if not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled" status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
) )
access_token = create_access_token( access_token = create_access_token(
data={"sub": user["username"]}, expires_delta=timedelta(days=7) data={"sub": user.username}, expires_delta=timedelta(days=7)
) )
return { return {
"access_token": access_token, "access_token": access_token,
"token_type": "bearer", "token_type": "bearer",
"user": { "user": {
"id": user["id"], "id": user.id,
"username": user["username"], "username": user.username,
"email": user.get("email"), "email": user.email,
"full_name": user.get("full_name"), "full_name": user.full_name,
}, },
} }
@@ -185,7 +217,7 @@ async def refresh_token(refresh_request: dict):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
) )
if not user.get("is_active", True): if not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled" status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
) )
+66 -47
View File
@@ -1,6 +1,7 @@
"""Application settings routes for Ohm Stream Downloader API""" """Application settings routes for Ohm Stream Downloader API"""
import json 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 import APIRouter, Depends, HTTPException, Request, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -8,7 +9,7 @@ from sqlmodel import Session, select
from app.database import get_session from app.database import get_session
from app.models.auth import User, UserTable from app.models.auth import User, UserTable
from app.models.settings import AppSettings, AppSettingsTable, AppSettingsUpdate 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 import get_anime_providers, get_series_providers
from app.providers_manager import providers_manager from app.providers_manager import providers_manager
@@ -19,23 +20,25 @@ templates = Jinja2Templates(directory="templates")
@router.get("", response_model=AppSettings) @router.get("", response_model=AppSettings)
async def get_settings( async def get_settings(
current_user: User = Depends(get_current_user_from_token), 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""" """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() settings_obj = session.exec(statement).first()
if not settings_obj: if not settings_obj:
# Create default settings if they don't exist # Create default settings if they don't exist
settings_obj = AppSettingsTable(user_id=current_user.id) settings_obj = AppSettingsTable(user_id=current_user.id)
session.add(settings_obj) session.add(settings_obj)
session.commit() session.commit()
session.refresh(settings_obj) session.refresh(settings_obj)
return AppSettings( return AppSettings(
default_lang=settings_obj.default_lang, default_lang=settings_obj.default_lang,
theme=settings_obj.theme, 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, update_data: AppSettingsUpdate,
response: Response, response: Response,
current_user: User = Depends(get_current_user_from_token), 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""" """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() settings_obj = session.exec(statement).first()
if not settings_obj: if not settings_obj:
settings_obj = AppSettingsTable(user_id=current_user.id) settings_obj = AppSettingsTable(user_id=current_user.id)
session.add(settings_obj) session.add(settings_obj)
if update_data.default_lang is not None: if update_data.default_lang is not None:
settings_obj.default_lang = update_data.default_lang settings_obj.default_lang = update_data.default_lang
if update_data.theme is not None: if update_data.theme is not None:
settings_obj.theme = update_data.theme settings_obj.theme = update_data.theme
if update_data.disabled_providers is not None: if update_data.disabled_providers is not None:
settings_obj.disabled_providers = update_data.disabled_providers settings_obj.disabled_providers = update_data.disabled_providers
session.add(settings_obj) session.add(settings_obj)
session.commit() session.commit()
session.refresh(settings_obj) 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 return settings_obj
@router.get("/providers/availability") @router.get("/providers/availability")
async def get_providers_availability( async def get_providers_availability(
current_user: User = Depends(get_current_user_from_token), 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 list of providers with their availability and enabled status"""
# Get user settings # 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() settings_obj = session.exec(statement).first()
disabled_providers = settings_obj.disabled_providers if settings_obj else [] disabled_providers = settings_obj.disabled_providers if settings_obj else []
# Get health status # Get health status
health_status = providers_manager.get_all_status() health_status = providers_manager.get_all_status()
# Combine anime and series providers # Combine anime and series providers
all_providers = {**get_anime_providers(), **get_series_providers()} all_providers = {**get_anime_providers(), **get_series_providers()}
result = [] result = []
for pid, info in all_providers.items(): for pid, info in all_providers.items():
status_info = health_status.get(pid, {"status": "unknown"}) status_info = health_status.get(pid, {"status": "unknown"})
result.append({ result.append(
"id": pid, {
"name": info["name"], "id": pid,
"icon": info.get("icon", "🎬"), "name": info["name"],
"status": status_info["status"], "icon": info.get("icon", "🎬"),
"enabled": pid not in disabled_providers, "status": status_info["status"],
"error": status_info.get("error") "enabled": pid not in disabled_providers,
}) "error": status_info.get("error"),
}
)
return result return result
@@ -107,16 +118,18 @@ async def toggle_provider(
provider_id: str, provider_id: str,
response: Response, response: Response,
current_user: User = Depends(get_current_user_from_token), 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""" """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() settings_obj = session.exec(statement).first()
if not settings_obj: if not settings_obj:
settings_obj = AppSettingsTable(user_id=current_user.id) settings_obj = AppSettingsTable(user_id=current_user.id)
session.add(settings_obj) session.add(settings_obj)
disabled = settings_obj.disabled_providers disabled = settings_obj.disabled_providers
if provider_id in disabled: if provider_id in disabled:
disabled.remove(provider_id) disabled.remove(provider_id)
@@ -124,33 +137,39 @@ async def toggle_provider(
else: else:
disabled.append(provider_id) disabled.append(provider_id)
enabled = False enabled = False
settings_obj.disabled_providers = disabled settings_obj.disabled_providers = disabled
session.add(settings_obj) session.add(settings_obj)
session.commit() session.commit()
status_text = "activé" if enabled else "désactivé" 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} return {"id": provider_id, "enabled": enabled}
@router.get("/ui") @router.get("/ui")
async def get_settings_ui( async def get_settings_ui(
request: Request, request: Request,
current_user: User = Depends(get_current_user_from_token), current_user: Optional[User] = Depends(get_optional_user),
session: Session = Depends(get_session) session: Session = Depends(get_session),
): ):
"""Return the settings UI fragment for HTMX""" if current_user is None:
# Reuse existing endpoints logic return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
settings = await get_settings(current_user, session) settings = await get_settings(current_user, session)
providers = await get_providers_availability(current_user, session) providers = await get_providers_availability(current_user, session)
return templates.TemplateResponse( return templates.TemplateResponse(
"components/settings_section.html", "components/settings_section.html",
{ {"request": request, "settings": settings, "providers": providers},
"request": request,
"settings": settings,
"providers": providers
}
) )
+86 -26
View File
@@ -6,7 +6,15 @@ import re
import json import json
from typing import List, Optional 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 fastapi.templating import Jinja2Templates
from app.download_manager import DownloadManager from app.download_manager import DownloadManager
@@ -20,7 +28,7 @@ from app.models.watchlist import (
WatchlistSettings, WatchlistSettings,
WatchlistStatus, 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"]) router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -28,6 +36,7 @@ templates = Jinja2Templates(directory="templates")
def get_download_manager() -> DownloadManager: def get_download_manager() -> DownloadManager:
from main import download_manager from main import download_manager
return download_manager return download_manager
@@ -41,38 +50,56 @@ async def add_to_watchlist(
from main import watchlist_manager from main import watchlist_manager
try: 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) 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" 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 return item
except Exception as e: except Exception as e:
from logging import getLogger from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
logger.error(f"Error adding to watchlist: {e}", exc_info=True) logger.error(f"Error adding to watchlist: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("", response_model=List[WatchlistItem]) @router.get("")
async def get_watchlist( async def get_watchlist(
request: Request, request: Request,
status: Optional[WatchlistStatus] = None, status: Optional[WatchlistStatus] = None,
html: bool = Query(False), 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 from main import watchlist_manager
items = watchlist_manager.get_all(user_id=current_user.id, status=status)
is_htmx = request.headers.get("HX-Request")
if html or request.headers.get("HX-Request"):
if current_user is None and (html or is_htmx):
return templates.TemplateResponse( return templates.TemplateResponse(
"components/watchlist_items_list.html", "components/login_prompt.html", {"request": request}
{"request": request, "items": items}
) )
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 return items
@@ -82,6 +109,7 @@ async def get_watchlist_settings(
): ):
"""Get global watchlist settings""" """Get global watchlist settings"""
from main import watchlist_manager from main import watchlist_manager
return watchlist_manager.get_settings() return watchlist_manager.get_settings()
@@ -97,9 +125,18 @@ async def update_watchlist_settings(
try: try:
updated_settings = watchlist_manager.update_settings(settings) updated_settings = watchlist_manager.update_settings(settings)
if auto_download_scheduler.is_running(): if auto_download_scheduler.is_running():
auto_download_scheduler.update_interval(updated_settings.check_interval_hours) 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"}}) )
response.headers["HX-Trigger"] = json.dumps(
{
"show-toast": {
"message": "Paramètres de la watchlist mis à jour",
"type": "success",
}
}
)
return updated_settings return updated_settings
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -112,6 +149,7 @@ async def get_watchlist_item(
): ):
"""Get a specific watchlist item""" """Get a specific watchlist item"""
from main import watchlist_manager from main import watchlist_manager
item = watchlist_manager.get_by_id(item_id) item = watchlist_manager.get_by_id(item_id)
if not item or item.user_id != current_user.id: if not item or item.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Item not found") 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") raise HTTPException(status_code=404, detail="Item not found")
updated_item = watchlist_manager.update(item_id, update_data) 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 return updated_item
@@ -153,10 +198,17 @@ async def delete_from_watchlist(
title = item.anime_title title = item.anime_title
if watchlist_manager.delete(item_id): 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 # HTMX will handle removing the element if target is specified in the frontend
return Response(status_code=204) return Response(status_code=204)
raise HTTPException(status_code=500, detail="Failed to delete item") 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""" """Trigger an immediate check for new episodes"""
from main import auto_download_scheduler from main import auto_download_scheduler
background_tasks.add_task(auto_download_scheduler.trigger_check_now) 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"} return {"status": "success", "message": "Check triggered"}
@@ -181,4 +240,5 @@ async def get_watchlist_stats(
): ):
"""Get watchlist statistics for the user""" """Get watchlist statistics for the user"""
from main import watchlist_manager from main import watchlist_manager
return watchlist_manager.get_stats(current_user.id) return watchlist_manager.get_stats(current_user.id)
+19 -2
View File
@@ -10,7 +10,7 @@ import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -21,10 +21,18 @@ from app.models import DownloadTask, DownloadStatus
# Configure logging # Configure logging
logger = logging.getLogger(__name__) 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 # Initialize FastAPI app
app = FastAPI(title="Ohm Stream Downloader") app = FastAPI(title="Ohm Stream Downloader")
# Configure CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=[
@@ -40,6 +48,14 @@ app.add_middleware(
allow_headers=["*"], 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 # Initialize download manager
download_manager = DownloadManager(download_dir="downloads", max_parallel=3) download_manager = DownloadManager(download_dir="downloads", max_parallel=3)
@@ -54,6 +70,7 @@ async def startup_event():
"""Initialize services on application startup""" """Initialize services on application startup"""
# Create database tables if they don't exist # Create database tables if they don't exist
from app.database import create_db_and_tables from app.database import create_db_and_tables
create_db_and_tables() create_db_and_tables()
logger.info("Database tables initialized") logger.info("Database tables initialized")
+58 -98
View File
@@ -170,140 +170,106 @@ h1 {
.btn-watch { background: var(--primary); color: #000; } .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; } .btn-download { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; }
/* Horizontal Rows (Netflix Style) */ /* Horizontal Scroll Row (Homepage) */
.streaming-row { .home-row, .streaming-row, .recommendations-carousel, .releases-carousel {
display: flex; display: flex;
gap: 20px; gap: 16px;
overflow-x: auto; overflow-x: auto;
padding: 20px 5px; padding: 10px 0 20px;
scroll-behavior: smooth; scroll-behavior: smooth;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.home-row::-webkit-scrollbar, .streaming-row::-webkit-scrollbar, .recommendations-carousel::-webkit-scrollbar, .releases-carousel::-webkit-scrollbar {
.streaming-row::-webkit-scrollbar { height: 4px;
height: 6px;
} }
.home-row::-webkit-scrollbar-thumb, .streaming-row::-webkit-scrollbar-thumb, .recommendations-carousel::-webkit-scrollbar-thumb, .releases-carousel::-webkit-scrollbar-thumb {
.streaming-row::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border-radius: 10px; border-radius: 10px;
} }
.home-row::-webkit-scrollbar-thumb:hover, .streaming-row::-webkit-scrollbar-thumb:hover, .recommendations-carousel::-webkit-scrollbar-thumb:hover, .releases-carousel::-webkit-scrollbar-thumb:hover {
.streaming-row::-webkit-scrollbar-thumb:hover {
background: var(--primary); background: var(--primary);
} }
/* Modern Card Design */ /* Home Card */
.anime-card { .hc {
flex: 0 0 220px; flex: 0 0 180px;
display: block;
background: var(--bg-card); background: var(--bg-card);
border-radius: var(--card-radius); border-radius: var(--card-radius);
overflow: hidden; overflow: hidden;
transition: var(--transition); transition: var(--transition);
position: relative;
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.05);
display: flex; text-decoration: none;
flex-direction: column; color: inherit;
} }
.hc:hover {
.anime-card:hover { transform: scale(1.08);
transform: scale(1.05);
z-index: 10; z-index: 10;
border-color: var(--primary); 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);
} }
.hc-poster {
.anime-poster {
position: relative; position: relative;
padding-top: 150%; padding-top: 150%;
background: #000; background: #000;
} }
.hc-poster img {
.anime-poster img {
position: absolute; position: absolute;
top: 0; left: 0; width: 100%; height: 100%; top: 0; left: 0; width: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: var(--transition);
} }
.hc-rating {
.anime-rating-badge {
position: absolute; position: absolute;
top: 10px; right: 10px; top: 8px; right: 8px;
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.85);
color: #ffcc00; color: #ffcc00;
padding: 4px 8px; padding: 2px 7px;
border-radius: 6px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.7rem;
font-weight: 800; font-weight: 800;
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 204, 0, 0.2);
} }
.hc-play {
.anime-overlay {
position: absolute; position: absolute;
top: 0; left: 0; width: 100%; height: 100%; bottom: 8px; right: 8px;
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%); width: 32px; height: 32px;
border-radius: 50%;
background: var(--primary);
color: var(--bg-dark);
display: flex; display: flex;
flex-direction: column; align-items: center;
justify-content: flex-end; justify-content: center;
padding: 15px; font-size: 0.75rem;
opacity: 0; opacity: 0;
transition: var(--transition); transition: var(--transition);
} }
.hc:hover .hc-play { opacity: 1; }
.anime-card:hover .anime-overlay { opacity: 1; } .hc-info {
padding: 10px;
.overlay-buttons {
display: flex;
gap: 10px;
justify-content: center;
} }
.hc-src {
/* Info Area */ font-size: 0.6rem;
.anime-info { font-weight: 700;
padding: 12px; text-transform: uppercase;
flex-grow: 1; color: var(--primary);
display: flex; letter-spacing: 0.5px;
flex-direction: column; display: block;
margin-bottom: 2px;
} }
.hc-title {
.anime-title { font-size: 0.82rem;
font-size: 0.95rem;
font-weight: 600; font-weight: 600;
margin-bottom: 8px; display: block;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
color: var(--text-main);
} }
.anime-meta-tags { /* Responsive Grid for Search */
display: flex; .anime-grid {
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 {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px; gap: 20px;
margin-bottom: 10px;
}
.btn-add-watchlist.followed {
border-color: var(--accent);
color: var(--accent);
background: rgba(0, 255, 136, 0.1);
} }
/* Tabs UI */ /* Tabs UI */
@@ -640,23 +606,17 @@ h1 {
opacity: 0.15; opacity: 0.15;
} }
.htmx-indicator { display: none; }
.htmx-indicator.htmx-request { display: flex; align-items: center; gap: 8px; }
/* Section Containers */ /* Section Containers */
.section-container { .section-container {
margin-bottom: 50px; 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) { @media (max-width: 768px) {
.anime-card { flex: 0 0 160px; } .anime-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 12px; }
.anime-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; } .hc { flex: 0 0 140px; }
.btn-card span { display: none; }
.btn-card { padding: 10px; }
.tabs { gap: 10px; } .tabs { gap: 10px; }
.auth-panel { flex-direction: column; gap: 15px; text-align: center; } .auth-panel { flex-direction: column; gap: 15px; text-align: center; }
} }
+56
View File
@@ -0,0 +1,56 @@
# Frontend JS (static/js/)
## OVERVIEW
Vanilla JavaScript modules loaded via `<script>` tags in HTML templates. No ES module imports in app code — uses global variables (`API_BASE`, `getToken()`) for cross-module communication. Tests use Vitest with ES module syntax.
## STRUCTURE
```
static/js/
├── main.js # Entry point — DOMContentLoaded, orchestrates tab navigation
├── api.js # API_BASE config, providers info, search caching
├── auth.js # Cookie-based token management (getToken, setToken)
├── auth-utils.js # safeJsonParse, displayError, displaySuccess
├── auth-api.js # login, register, logout, getMe API calls
├── auth-ui.js # handleLogin, handleRegister, handleLogout UI handlers
├── anime.js # loadAnimeReleases (partially HTMX, legacy)
├── anime-details.js # searchAnimeDetails, episode management (555 lines)
├── series-search.js # handleSeriesSearch for FS7 provider
├── watchlist.js # Watchlist CRUD API calls (461 lines)
├── watchlist-ui.js # displayWatchlist (legacy, redirects to HTMX)
├── tabs.js # renderSeriesRecommendationCard, tab switching
├── downloads.js # loadDownloads (legacy, redirects to HTMX)
├── recommendations.js # loadRecommendations
├── utils.js # formatBytes utility
└── __tests__/ # Vitest test files
├── smoke.test.js
├── auth-api.test.js
└── auth-utils.test.js
```
## WHERE TO LOOK
| Need | File | Notes |
|------|------|-------|
| API base URL / config | `api.js` | Defines `API_BASE` global |
| Auth token access | `auth.js` | `getToken()` used by most API-calling modules |
| Add API endpoint call | Module calling the API | Use `fetch(API_BASE + '/api/...')` + `getToken()` header |
| Add UI component | `auth-ui.js`, `tabs.js` | Alpine.js used for state, HTMX for server interactions |
| Run JS tests | `__tests__/` | `npm test` (Vitest) |
## CONVENTIONS
**Module communication**: Global variables, not ES imports. `api.js` defines `API_BASE`. `auth.js` defines `getToken()`. Other modules consume these globals.
**Loading order matters**: Scripts loaded via `<script>` tags in `base.html` — order defines availability of globals.
**Auth subsystem chain**: `auth.js` (token storage) → `auth-utils.js` (utilities) → `auth-api.js` (API calls) → `auth-ui.js` (UI handlers).
**HTMX + Alpine.js**: Most interactions now use HTMX (server-driven). Alpine.js handles client-side state (modals, toggles, tabs). Legacy JS modules (downloads.js, watchlist-ui.js) redirect to HTMX equivalents.
**Tests**: Vitest with jsdom environment. Test files define skeleton functions matching source — not importing actual source files.
## ANTI-PATTERNS
- Do NOT add ES module `import`/`export` syntax to app JS files — they use global scope
- Do NOT depend on script load order without checking — add null guards
- Do NOT duplicate API call patterns — centralize in `api.js`
+3 -6
View File
@@ -41,11 +41,10 @@ function removeToken() {
// Check if user is authenticated // Check if user is authenticated
async function checkAuth() { async function checkAuth() {
console.log('Checking authentication...');
const token = getToken(); const token = getToken();
if (!token) { if (!token) {
console.log('No token found'); window.dispatchEvent(new CustomEvent('auth-logout'));
return false; return false;
} }
@@ -56,20 +55,18 @@ async function checkAuth() {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
console.log('Auth success:', data.user.username);
// Dispatch for Alpine
window.dispatchEvent(new CustomEvent('auth-success', { window.dispatchEvent(new CustomEvent('auth-success', {
detail: { username: data.user.full_name || data.user.username } detail: { username: data.user.full_name || data.user.username }
})); }));
return true; return true;
} else { } else {
console.log('Token invalid'); removeToken();
window.dispatchEvent(new CustomEvent('auth-logout'));
return false; return false;
} }
} catch (error) { } catch (error) {
console.error('Auth check error:', error);
return false; return false;
} }
} }
-1
View File
@@ -4,7 +4,6 @@
*/ */
async function loadDownloads() { async function loadDownloads() {
console.log('Legacy loadDownloads called - redirected to HTMX refresh');
if (typeof htmx !== 'undefined') { if (typeof htmx !== 'undefined') {
htmx.trigger('#downloads-container-inner', 'refresh'); htmx.trigger('#downloads-container-inner', 'refresh');
} }
+14 -3
View File
@@ -18,6 +18,16 @@
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
</style> </style>
<!-- Configure HTMX to include auth token in all requests -->
<script>
document.addEventListener('htmx:configRequest', (event) => {
const token = localStorage.getItem('auth_token');
if (token) {
event.detail.headers['Authorization'] = `Bearer ${token}`;
}
});
</script>
<!-- Legacy JavaScript (Refactored to HTMX/Alpine) --> <!-- Legacy JavaScript (Refactored to HTMX/Alpine) -->
<script src="/static/js/auth.js?v=1.10" defer></script> <script src="/static/js/auth.js?v=1.10" defer></script>
<script src="/static/js/api.js?v=1.11" defer></script> <script src="/static/js/api.js?v=1.11" defer></script>
@@ -46,14 +56,15 @@
isAuthenticated: true, isAuthenticated: true,
username: '', username: '',
init() { init() {
console.log('Global app state ready');
window.addEventListener('auth-success', (e) => { window.addEventListener('auth-success', (e) => {
console.log('Alpine auth-success received');
this.isAuthenticated = true; this.isAuthenticated = true;
this.username = e.detail.username; this.username = e.detail.username;
}); });
window.addEventListener('auth-logout', () => {
this.isAuthenticated = false;
this.username = '';
});
window.addEventListener('set-tab', (e) => { window.addEventListener('set-tab', (e) => {
console.log('Alpine set-tab received:', e.detail.tab);
this.activeTab = e.detail.tab; this.activeTab = e.detail.tab;
}); });
} }
+10 -63
View File
@@ -1,71 +1,18 @@
{% macro anime_card(anime, in_watchlist=False, lang='vostfr') %} {% macro anime_card(anime, in_watchlist=False, lang='vostfr') %}
<div class="anime-card" id="anime-{{ anime.url | hash }}"> <div class="hc" id="anime-{{ anime.url | hash }}"
<div class="anime-poster"> @click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } })); $nextTick(() => { const input = document.getElementById('animeSearchInput'); if (input) { input.value = '{{ anime.title | e }}'; htmx.trigger(input, 'keyup'); } });">
<div class="hc-poster">
{% 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' %} {% 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' %}
<img src="{{ poster }}" <img src="{{ poster }}" alt="{{ anime.title }}" loading="lazy" referrerpolicy="no-referrer"
alt="{{ anime.title }}" onerror="this.src='https://placehold.co/400x600/161625/00d9ff?text=Error'; this.onerror=null;">
loading="lazy"
referrerpolicy="no-referrer"
onerror="this.src='https://placehold.co/400x600/161625/00d9ff?text=Image+Error'; this.onerror=null;">
{% if anime.metadata and anime.metadata.rating %} {% if anime.metadata and anime.metadata.rating %}
<div class="anime-rating-badge"> <span class="hc-rating"><i class="fas fa-star"></i> {{ anime.metadata.rating }}</span>
<i class="fas fa-star"></i> {{ anime.metadata.rating }}
</div>
{% endif %} {% endif %}
<span class="hc-play"><i class="fas fa-search"></i></span>
<div class="anime-overlay">
<div class="overlay-buttons">
<button class="btn btn-primary btn-circle"
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML"
title="Play">
<i class="fas fa-play"></i>
</button>
</div>
</div>
</div> </div>
<div class="hc-info">
<div class="anime-info"> <span class="hc-src">{{ anime.provider_id or 'Anime' }}</span>
<h3 class="anime-title" title="{{ anime.title }}">{{ anime.title }}</h3> <span class="hc-title">{{ anime.title }}</span>
<div class="anime-meta-tags">
<span class="badge">{{ anime.provider_id or 'Anime' }}</span>
<span class="badge" style="color: var(--primary)">{{ lang | upper }}</span>
{% if anime.metadata and anime.metadata.status %}
<span class="badge" style="color: #aaa">{{ anime.metadata.status }}</span>
{% endif %}
</div>
<div class="anime-card-buttons">
<button class="btn btn-primary btn-small"
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML">
<i class="fas fa-eye"></i> <span>Regarder</span>
</button>
<button class="btn btn-secondary btn-small"
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML">
<i class="fas fa-download"></i> <span>Télécharger</span>
</button>
</div>
{% if not in_watchlist %}
<button class="btn btn-secondary btn-small btn-block"
hx-post="/api/watchlist"
hx-vals='{"anime_url": "{{ anime.url }}", "anime_title": "{{ anime.title }}", "provider_id": "{{ anime.provider_id }}", "lang": "{{ lang }}"}'
hx-swap="none"
hx-on::after-request="this.innerHTML='<i class=\'fas fa-check\'></i> Suivi'; this.disabled=true; this.classList.add('followed')">
<i class="fas fa-plus"></i> Watchlist
</button>
{% else %}
<button class="btn btn-secondary btn-small btn-block followed" disabled>
<i class="fas fa-check"></i> Suivi
</button>
{% endif %}
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
+142 -24
View File
@@ -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' %}
<div class="search-results-container"> {% set _groups = namespace(items={}) %}
{% if results %} {% for pid, items in (results or {}).items() %}
{% for provider_id, items in results.items() %} {% for item in items %}
<div class="provider-section"> {% set _key = item.title | lower | trim %}
<h3 class="provider-title">{{ provider_id | upper }}</h3> {% if _key not in _groups.items %}
<div class="anime-grid"> {% set _ = _groups.items.update({_key: {
{% for anime in items %} "title": item.title,
{{ anime_card(anime, lang=settings.default_lang if settings else 'vostfr') }} "cover": item.cover_image or (item.metadata.poster_image if item.metadata else "") or "",
{% endfor %} "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 %}
<div class="sr-list" x-data="{ openDropdown: null }">
{% if _groups.items.values() | list | length > 0 %}
{% for group in _groups.items.values() | list %}
{% set first_url = group.providers[0].url %}
<div class="sr-card" style="--sr-accent: {{ accent }};">
<a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener">
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/161625/00d9ff?text=No+Image' }}"
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
onerror="this.src='https://placehold.co/240x360/161625/00d9ff?text=Error'; this.onerror=null;">
</a>
<div class="sr-body">
<div class="sr-top">
<h3 class="sr-title">{{ group.title }}</h3>
{% if group.rating %}
<span class="sr-rating"><i class="fas fa-star"></i> {{ group.rating }}</span>
{% endif %}
</div>
{% if group.synopsis %}
<p class="sr-synopsis">{{ group.synopsis[:200] }}{% if group.synopsis | length > 200 %}...{% endif %}</p>
{% endif %}
{% if group.genres %}
<div class="sr-tags">
{% for g in group.genres[:5] %}
<span class="sr-tag">{{ g }}</span>
{% endfor %}
</div>
{% endif %}
<div class="sr-providers">
{% for p in group.providers %}
<a class="sr-provider-badge" href="{{ p.url }}" target="_blank" rel="noopener">{{ p.id | upper }}</a>
{% endfor %}
</div>
<div class="sr-actions">
<a class="sr-btn sr-btn-watch" href="{{ first_url }}" target="_blank" rel="noopener">
<i class="fas fa-play"></i> Regarder
</a>
<div class="sr-dropdown" @click.outside="openDropdown = null">
<button class="sr-btn sr-btn-dl" @click="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
<i class="fas fa-download"></i> Telecharger <i class="fas fa-chevron-down" style="font-size:0.6rem;margin-left:4px;"></i>
</button>
<div class="sr-dropdown-menu" x-show="openDropdown === '{{ first_url | urlencode }}'" x-transition>
<button class="sr-dropdown-item"
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
hx-swap="none"
hx-on::after-request="openDropdown = null">
<i class="fas fa-layer-group"></i> Saison complete
</button>
<button class="sr-dropdown-item"
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}"
hx-target="#dl-episodes-{{ loop.index }}"
hx-swap="innerHTML"
hx-on::after-request="openDropdown = null">
<i class="fas fa-list-ol"></i> Choisir des episodes
</button>
<div id="dl-episodes-{{ loop.index }}"></div>
</div>
</div>
<button class="sr-btn sr-btn-follow"
hx-post="/api/watchlist"
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}'
hx-swap="none"
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
<i class="fas fa-plus"></i> Suivre
</button>
</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="no-results"> <div class="sr-empty">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
<p>Aucun anime trouvé pour votre recherche.</p> <p>Aucun anime trouve pour votre recherche.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<style> <style>
.provider-section { margin-bottom: 40px; } .sr-list { display: flex; flex-direction: column; gap: 16px; }
.provider-title { .sr-card {
color: var(--primary); display: flex; gap: 20px;
margin-bottom: 20px; background: var(--bg-card); border-radius: var(--card-radius);
font-size: 1.2rem; padding: 20px; border: 1px solid rgba(255,255,255,0.05);
text-transform: uppercase; transition: var(--transition);
letter-spacing: 1px;
} }
.no-results { .sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
text-align: center; .sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
padding: 100px 20px; .sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
color: var(--text-dim); .sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.sr-top { display: flex; align-items: baseline; gap: 12px; }
.sr-title { font-size: 1.1rem; font-weight: 700; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sr-rating { flex-shrink: 0; font-size: 0.8rem; font-weight: 700; color: #ffcc00; }
.sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.sr-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; }
.sr-tag { font-size: 0.65rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-dim); }
.sr-providers { display: flex; flex-wrap: wrap; gap: 6px; }
.sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; }
.sr-provider-badge:hover { background: var(--sr-accent); color: var(--bg-dark); }
.sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.sr-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); }
.sr-btn:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); }
.sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); }
.sr-btn-watch:hover { background: var(--sr-accent); color: var(--bg-dark); }
.sr-btn-follow { border-color: var(--accent); color: var(--accent); }
.sr-btn-follow:hover { background: var(--accent); color: var(--bg-dark); }
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; }
.sr-dropdown { position: relative; }
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 4px; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 6px; transition: var(--transition); text-align: left; }
.sr-dropdown-item:hover { background: rgba(255,255,255,0.06); }
.sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); }
.sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
@media (max-width: 768px) {
.sr-card { flex-direction: column; align-items: center; text-align: center; gap: 12px; padding: 16px; }
.sr-poster-link { width: 160px; }
.sr-top { justify-content: center; }
.sr-tags { justify-content: center; }
.sr-providers { justify-content: center; }
.sr-actions { justify-content: center; }
} }
.no-results i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
</style> </style>
+2 -1
View File
@@ -9,8 +9,9 @@
<span style="color: #fff; font-size: 14px;">Connecté en tant que <strong x-text="username" style="color: var(--primary);">-</strong></span> <span style="color: #fff; font-size: 14px;">Connecté en tant que <strong x-text="username" style="color: var(--primary);">-</strong></span>
</div> </div>
<button class="btn btn-secondary btn-small" <button class="btn btn-secondary btn-small"
onclick="removeToken(); isAuthenticated = false"
hx-post="/api/auth/logout" hx-post="/api/auth/logout"
hx-on::after-request="isAuthenticated = false; window.location.reload()"> hx-on::after-request="window.location.href = '/login'">
🚪 Déconnexion 🚪 Déconnexion
</button> </button>
</div> </div>
+4 -9
View File
@@ -1,9 +1,5 @@
<!-- Home Section: Premium Layout -->
<div id="tab-home" class="tab-content" x-show="activeTab === 'home'"> <div id="tab-home" class="tab-content" x-show="activeTab === 'home'">
<!-- Hero / Featured area could go here later -->
<!-- Recommendations Row -->
<div class="section-container"> <div class="section-container">
<div class="section-header"> <div class="section-header">
<h2>🎯 Recommandé pour vous</h2> <h2>🎯 Recommandé pour vous</h2>
@@ -16,12 +12,11 @@
<div id="recommendationsList" <div id="recommendationsList"
hx-get="/api/recommendations" hx-get="/api/recommendations"
hx-trigger="load delay:100ms" hx-trigger="load delay:100ms"
class="streaming-row"> class="home-row">
<div class="loading-spinner"></div> <div class="loading-placeholder"><div class="spinner"></div></div>
</div> </div>
</div> </div>
<!-- Latest Releases Row -->
<div class="section-container"> <div class="section-container">
<div class="section-header"> <div class="section-header">
<h2>🔥 Dernières sorties</h2> <h2>🔥 Dernières sorties</h2>
@@ -34,8 +29,8 @@
<div id="releasesList" <div id="releasesList"
hx-get="/api/releases/latest" hx-get="/api/releases/latest"
hx-trigger="load delay:300ms" hx-trigger="load delay:300ms"
class="streaming-row"> class="home-row">
<div class="loading-spinner"></div> <div class="loading-placeholder"><div class="spinner"></div></div>
</div> </div>
</div> </div>
</div> </div>
+4
View File
@@ -0,0 +1,4 @@
<div class="login-prompt" style="text-align: center; padding: 40px 20px;">
<i class="fas fa-lock" style="font-size: 2rem; color: #00d9ff; margin-bottom: 15px;"></i>
<p style="color: #888; font-size: 0.95rem;">Connectez-vous pour accéder à cette section.</p>
</div>
+13 -53
View File
@@ -1,58 +1,18 @@
{% macro series_card(series, in_watchlist=False, lang='vf') %} {% macro series_card(series, in_watchlist=False, lang='vf') %}
<div class="anime-card" id="series-{{ series.url | hash }}"> <div class="ac" id="series-{{ series.url | hash }}">
<div class="anime-poster"> <div class="ac-poster">
<img src="{{ series.cover_image or 'https://placehold.co/400x600/161625/ff6b6b?text=No+Image' }}" <img src="{{ series.cover_image or 'https://placehold.co/400x600/161625/ff6b6b?text=No+Image' }}"
alt="{{ series.title }}" alt="{{ series.title }}" loading="lazy" referrerpolicy="no-referrer"
loading="lazy" onerror="this.src='https://placehold.co/400x600/161625/ff6b6b?text=Error'; this.onerror=null;">
referrerpolicy="no-referrer" <button class="ac-play"
onerror="this.src='https://placehold.co/400x600/161625/ff6b6b?text=Image+Error'; this.onerror=null;"> hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
<div class="anime-overlay"> hx-target="#player-container" hx-swap="innerHTML">
<div class="overlay-buttons"> <i class="fas fa-play"></i>
<button class="btn btn-primary btn-circle" </button>
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML"
title="Play">
<i class="fas fa-play"></i>
</button>
</div>
</div>
</div> </div>
<div class="anime-info"> <div class="ac-info">
<h3 class="anime-title" title="{{ series.title }}">{{ series.title }}</h3> <span class="ac-provider" style="--ac-color: var(--secondary)">{{ series.provider_id | upper if series.provider_id else 'FS7' }}</span>
<div class="anime-meta-tags"> <h3 class="ac-title" title="{{ series.title }}">{{ series.title }}</h3>
<span class="badge">{{ series.provider_id | upper if series.provider_id else 'FS7' }}</span>
<span class="badge" style="color: var(--primary)">{{ lang | upper }}</span>
</div>
<div class="anime-card-buttons">
<button class="btn btn-primary btn-small"
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML">
<i class="fas fa-eye"></i> <span>Regarder</span>
</button>
<button class="btn btn-secondary btn-small"
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML">
<i class="fas fa-download"></i> <span>Télécharger</span>
</button>
</div>
{% if not in_watchlist %}
<button class="btn btn-secondary btn-small btn-block"
hx-post="/api/watchlist"
hx-vals='{"anime_url": "{{ series.url }}", "anime_title": "{{ series.title }}", "provider_id": "{{ series.provider_id or 'fs7' }}", "lang": "{{ lang }}"}'
hx-swap="none"
hx-on::after-request="this.innerHTML='<i class=\'fas fa-check\'></i> Suivi'; this.disabled=true; this.classList.add('followed')">
<i class="fas fa-plus"></i> Watchlist
</button>
{% else %}
<button class="btn btn-secondary btn-small btn-block followed" disabled>
<i class="fas fa-check"></i> Suivi
</button>
{% endif %}
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
+116 -24
View File
@@ -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' %}
<div class="search-results-container"> {% set _groups = namespace(items={}) %}
{% if results %} {% for pid, items in (results or {}).items() %}
{% for provider_id, items in results.items() %} {% for item in items %}
<div class="provider-section"> {% set _key = item.title | lower | trim %}
<h3 class="provider-title">{{ provider_id | upper }}</h3> {% if _key not in _groups.items %}
<div class="anime-grid"> {% set _ = _groups.items.update({_key: {
{% for series in items %} "title": item.title,
{{ series_card(series, lang=settings.default_lang if settings else 'vf') }} "cover": item.cover_image or "",
{% endfor %} "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 %}
<div class="sr-list" x-data="{ openDropdown: null }">
{% if _groups.items.values() | list | length > 0 %}
{% for group in _groups.items.values() | list %}
{% set first_url = group.providers[0].url %}
<div class="sr-card" style="--sr-accent: {{ accent }};">
<a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener">
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/161625/ff6b6b?text=No+Image' }}"
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
onerror="this.src='https://placehold.co/240x360/161625/ff6b6b?text=Error'; this.onerror=null;">
</a>
<div class="sr-body">
<h3 class="sr-title">{{ group.title }}</h3>
{% if group.synopsis %}
<p class="sr-synopsis">{{ group.synopsis[:200] }}{% if group.synopsis | length > 200 %}...{% endif %}</p>
{% endif %}
<div class="sr-providers">
{% for p in group.providers %}
<a class="sr-provider-badge" href="{{ p.url }}" target="_blank" rel="noopener">{{ p.id | upper }}</a>
{% endfor %}
</div>
<div class="sr-actions">
<a class="sr-btn sr-btn-watch" href="{{ first_url }}" target="_blank" rel="noopener">
<i class="fas fa-play"></i> Regarder
</a>
<div class="sr-dropdown" @click.outside="openDropdown = null">
<button class="sr-btn sr-btn-dl" @click="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
<i class="fas fa-download"></i> Telecharger <i class="fas fa-chevron-down" style="font-size:0.6rem;margin-left:4px;"></i>
</button>
<div class="sr-dropdown-menu" x-show="openDropdown === '{{ first_url | urlencode }}'" x-transition>
<button class="sr-dropdown-item"
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
hx-swap="none"
hx-on::after-request="openDropdown = null">
<i class="fas fa-layer-group"></i> Saison complete
</button>
<button class="sr-dropdown-item"
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}"
hx-target="#dl-episodes-{{ loop.index }}"
hx-swap="innerHTML"
hx-on::after-request="openDropdown = null">
<i class="fas fa-list-ol"></i> Choisir des episodes
</button>
<div id="dl-episodes-{{ loop.index }}"></div>
</div>
</div>
<button class="sr-btn sr-btn-follow"
hx-post="/api/watchlist"
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}'
hx-swap="none"
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
<i class="fas fa-plus"></i> Suivre
</button>
</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="no-results"> <div class="sr-empty">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
<p>Aucune série TV trouvée pour votre recherche.</p> <p>Aucune serie TV trouvee pour votre recherche.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<style> <style>
.provider-section { margin-bottom: 40px; } .sr-list { display: flex; flex-direction: column; gap: 16px; }
.provider-title { .sr-card {
color: var(--secondary); display: flex; gap: 20px;
margin-bottom: 20px; background: var(--bg-card); border-radius: var(--card-radius);
font-size: 1.2rem; padding: 20px; border: 1px solid rgba(255,255,255,0.05);
text-transform: uppercase; transition: var(--transition);
letter-spacing: 1px;
} }
.no-results { .sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
text-align: center; .sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
padding: 100px 20px; .sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
color: var(--text-dim); .sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.sr-title { font-size: 1.1rem; font-weight: 700; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.sr-providers { display: flex; flex-wrap: wrap; gap: 6px; }
.sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; }
.sr-provider-badge:hover { background: var(--sr-accent); color: var(--bg-dark); }
.sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.sr-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); }
.sr-btn:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); }
.sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); }
.sr-btn-watch:hover { background: var(--sr-accent); color: var(--bg-dark); }
.sr-btn-follow { border-color: var(--accent); color: var(--accent); }
.sr-btn-follow:hover { background: var(--accent); color: var(--bg-dark); }
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; }
.sr-dropdown { position: relative; }
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 4px; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 6px; transition: var(--transition); text-align: left; }
.sr-dropdown-item:hover { background: rgba(255,255,255,0.06); }
.sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); }
.sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
@media (max-width: 768px) {
.sr-card { flex-direction: column; align-items: center; text-align: center; gap: 12px; padding: 16px; }
.sr-poster-link { width: 160px; }
.sr-title { white-space: normal; text-overflow: initial; }
.sr-providers { justify-content: center; }
.sr-actions { justify-content: center; }
} }
.no-results i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
</style> </style>
+2 -2
View File
@@ -35,7 +35,7 @@
Rechercher Rechercher
</button> </button>
</form> </form>
<div id="search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--primary); display: none;"> <div id="search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--primary);">
<div class="spinner"></div> Recherche en cours... <div class="spinner"></div> Recherche en cours...
</div> </div>
<div style="margin-top: 15px; padding: 12px; background: rgba(0, 217, 255, 0.05); border: 1px solid rgba(0, 217, 255, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);"> <div style="margin-top: 15px; padding: 12px; background: rgba(0, 217, 255, 0.05); border: 1px solid rgba(0, 217, 255, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
@@ -92,7 +92,7 @@
Rechercher Rechercher
</button> </button>
</form> </form>
<div id="series-search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--secondary); display: none;"> <div id="series-search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--secondary);">
<div class="spinner"></div> Recherche en cours... <div class="spinner"></div> Recherche en cours...
</div> </div>
<div style="margin-top: 15px; padding: 12px; background: rgba(255, 107, 107, 0.05); border: 1px solid rgba(255, 107, 107, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);"> <div style="margin-top: 15px; padding: 12px; background: rgba(255, 107, 107, 0.05); border: 1px solid rgba(255, 107, 107, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
+33 -41
View File
@@ -1,38 +1,35 @@
# AGENTS.md - Test Suite # AGENTS.md - Test Suite
## OVERVIEW ## OVERVIEW
Pytest suite: 18+ test files, 5000+ lines. Auto-marked (unit/integration/asyncio) + manual markers (slow/network).
Pytest test suite for Ohm Stream Downloader with 18 test files covering unit and integration tests.
## STRUCTURE ## STRUCTURE
``` ```
tests/ tests/
├── conftest.py # Fixtures & pytest config ├── conftest.py # 301 lines: fixtures, markers, DB isolation, event loop
├── test_*.py # 18 test modules ├── e2e/ # Playwright end-to-end tests
├── test_api.py # FastAPI endpoints (integration) ├── test_api.py # 627 lines — FastAPI endpoints (auto integration)
├── test_auth.py # JWT authentication ├── test_favorites.py # 564 lines — Favorites CRUD
├── test_download_manager.py # Download queue management ├── test_sonarr.py # 513 lines — Sonarr webhook
├── test_downloaders.py # Provider downloaders ├── test_metadata_enrichment.py # 442 lines — Kitsu API
├── test_anime_sama_*.py # Anime-Sama provider variants ├── test_download_manager.py # 395 lines — Download queue
├── test_favorites.py # Favorites management ├── test_downloaders.py # 341 lines — Provider scrapers
├── test_french_manga.py # French-Manga provider ├── test_models.py # 321 lines — Pydantic models
├── test_models.py # Pydantic model validation ├── test_utils.py # 246 lines — sanitize_filename, is_safe_filename
── test_sonarr.py # Sonarr webhook integration ── test_watchlist.py # 178 lines — Auto-download watchlist
├── test_utils.py # Utility functions
├── test_watchlist.py # Auto-download watchlist
├── test_metadata_enrichment.py
├── test_translate_api.py
├── test_delete_and_restore.py
``` ```
**Root-level tests** (legacy placement, NOT in tests/):
- `test_watchlist_simple.py`, `test_watchlist_e2e.py` — should be moved to tests/
## WHERE TO LOOK ## WHERE TO LOOK
| Need | File | | Need | File |
|------|------| |------|------|
| Run all tests | `pytest` | | All tests | `pytest` |
| Unit tests only | `pytest -m "unit"` | | Unit only | `pytest -m "unit"` |
| Integration tests | `pytest -m "integration"` (test_api.py auto-marked) | | Integration only | `pytest -m "integration"` (test_api.py auto-marked) |
| Skip slow | `pytest -m "not slow"` |
| Download logic | `test_download_manager.py`, `test_downloaders.py` | | Download logic | `test_download_manager.py`, `test_downloaders.py` |
| API endpoints | `test_api.py` | | API endpoints | `test_api.py` |
| Provider scrapers | `test_anime_sama_*.py`, `test_french_manga.py` | | Provider scrapers | `test_anime_sama_*.py`, `test_french_manga.py` |
@@ -40,25 +37,20 @@ tests/
## CONVENTIONS ## CONVENTIONS
**Markers** (auto-applied unless manual): **Markers** (auto-applied unless manual):
- `unit` - Default for non-api tests - `unit` Default for non-api tests
- `integration` - test_api.py only - `integration` test_api.py only
- `asyncio` - Auto-detected from coroutine functions - `asyncio` Auto-detected from coroutine functions
- `slow` - Manual: `@pytest.mark.slow` - `slow` Manual: `@pytest.mark.slow`
- `network` - Manual: `@pytest.mark.network` - `network` Manual: `@pytest.mark.network`
**Naming**: **DB isolation**: `conftest.py` forces `DATABASE_URL=sqlite://` in-memory. Tables auto-drop/recreate per test.
- Files: `test_*.py`
- Classes: `Test*` (e.g., `class TestSanitizeFilename:`)
- Functions: `test_*` (e.g., `def test_sanitize_simple_filename(self):`)
**Fixtures** (in conftest.py): **Naming**: Files `test_*.py`, classes `Test*`, functions `test_*`.
- `temp_dir` - Temporary directory (auto-cleanup)
- `temp_download_dir` - Download folder
- `sample_download_task` - DownloadTask instance
- `mock_httpx_client` - Mocked AsyncClient
- `download_manager` - Pre-configured DownloadManager
**Run commands**: **Config**: `pytest.ini` — asyncio_mode=auto, timeout=300s, coverage on app/.
- `pytest` - All tests with coverage
- `pytest -m "not slow"` - Skip slow tests ## ANTI-PATTERNS
- `pytest --cov=app --cov-report=html` - HTML coverage report
- Do NOT add network-dependent tests without `@pytest.mark.network`
- Do NOT add slow tests without `@pytest.mark.slow`
- Empty `except:` in `test_api.py:429,451` and `test_download_manager.py:357` — known tech debt