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
# Setup
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
# Run dev server
# Dev server
uvicorn main:app --reload --host 0.0.0.0 --port 3000
```
## Build, Lint & Test Commands
# --- Tests (pytest, configured in pytest.ini, asyncio_mode=auto) ---
### Running Tests
pytest # All tests (coverage + verbose by default)
pytest -m "unit" # Fast unit tests only
pytest -m "integration" # API integration tests
pytest -m "not slow" # CI default — excludes slow tests
pytest -m "network" # Tests requiring network access
```bash
# All tests
pytest
# With coverage (HTML report in htmlcov/)
pytest --cov=app --cov-report=html
# Unit only (fast)
pytest -m "unit"
# Integration tests only
pytest -m "integration"
# Exclude slow tests
pytest -m "not slow"
# Exclude network tests (mocked only)
pytest -m "not network"
# Verbose with print debugging
pytest -v -s
# Generate HTML report
pytest --html=report.html --self-contained-html
# Timeout per test (seconds)
pytest --timeout=30
```
### Running Single Tests
```bash
# Specific file
# Single file / class / test
pytest tests/test_sonarr.py -v
# Specific class
pytest tests/test_sonarr.py::TestSonarrHandler -v
# Specific test
pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
# Pattern match
pytest -k "test_download" -v
# Debug
pytest -s # Show print() output
pytest --cov=app --cov-report=html # HTML coverage report in htmlcov/
# --- Lint & Format (ruff) ---
ruff check app/ # Lint
ruff format --check app/ # Format check (CI enforces this)
ruff format app/ # Auto-format
# --- Type Check ---
mypy app/ --ignore-missing-imports # Type check (CI enforces)
# --- DB Migrations ---
alembic revision --autogenerate -m "description"
alembic upgrade head
# --- Frontend (optional) ---
npm test # Vitest JS tests
npx playwright test # E2E browser tests
```
## Code Style
## CODE STYLE
### Imports (PEP 8 order)
1. Standard library (`os`, `json`, `asyncio`)
2. Third-party (`httpx`, `beautifulsoup4`, `fastapi`)
3. Local app (`app.config`, `app.utils`)
```python
import os
import asyncio
from typing import Optional
import httpx
from fastapi import APIRouter, HTTPException
from app.config import get_settings
from app.models import DownloadTask, DownloadStatus
```
### Imports
Three groups separated by blank lines: stdlib → third-party → local (`app.*`).
Always absolute imports (no relative). No wildcard imports (`from X import *` enforced).
### Formatting
- **Line length**: 120 chars max
- **Indentation**: 4 spaces
- **Blank lines**: 2 between top-level, 1 between inline
PEP 8, 120 chars max line length. Single quotes for strings, double quotes for docstrings.
Ruff handles linting and formatting (no local config — CI-only).
### Type Annotations
- Use explicit types
- Use `Optional[X]` not `X | None`
- Use `list[X]`, `dict[X, Y]`
### Types
Explicit type hints on all function signatures and return types.
Use `Optional[X]` from `typing`. Use `list[str]` / `dict[str, X]` (lowercase generics).
Pydantic models for all API schemas. Return type annotations required on public methods.
```python
# Good
async def get_download_link(url: str, target_filename: Optional[str] = None) -> tuple[str, str]:
results: list[dict[str, str]] = []
# Avoid
async def get_download_link(url, target_filename=None):
results = []
```
### Naming Conventions
| Element | Convention | Example |
|---------|------------|---------|
| Modules | snake_case | `download_manager.py` |
| Classes | PascalCase | `DownloadManager` |
| Functions | snake_case | `get_download_link()` |
| Constants | UPPER_SNAKE | `MAX_PARALLEL_DOWNLOADS` |
| Variables | snake_case | `download_task` |
| Enums | PascalCase | `DownloadStatus` |
| Enum values | UPPER_SNAKE | `DownloadStatus.PENDING` |
### Async/Await
- Always use for I/O operations
- Close clients properly to avoid leaks
```python
async def close(self):
await self.client.aclose()
```
### Naming
- `snake_case` for functions, variables, constants
- `PascalCase` for classes and enums
- `UPPER_SNAKE_CASE` for enum values (`DownloadStatus.PENDING`)
- `logger = logging.getLogger(__name__)` at module level
- `_` prefix for private methods (`_fetch_page`, `_sanitize`)
- `get_*` for factory functions (`get_downloader`, `get_anime_site`)
### Error Handling
- Use try/except for recoverable errors
- Raise specific exceptions (`HTTPException`, `ValueError`)
- Never use empty except blocks
- Log errors appropriately
- `HTTPException` for API errors with proper status codes
- `raise ValueError()` for business logic validation
- `try/except` with logging — never bare `except:` (known tech debt exists)
- `response.raise_for_status()` for HTTP errors
- Never return `None` for missing URLs from downloaders — raise an exception
```python
try:
result = await client.get(url)
except httpx.TimeoutException:
logger.warning(f"Request timeout for {url}")
raise HTTPException(status_code=504, detail="Request timeout")
### Docstrings
Triple double quotes `"""`. Module docstrings describe purpose. Class docstrings describe
responsibility. Complex methods use Args/Returns sections. Not all methods require docstrings.
## ARCHITECTURE
```
main.py # App entry, middleware, startup, router registration
app/
├── routers/ # 11 APIRouter modules (one per feature domain)
├── downloaders/ # 3-tier: anime_sites/ → series_sites/ → video_players/
├── models/ # Pydantic/SQLModel (Base → Table → Schema pattern)
├── config.py # Pydantic Settings from .env
├── database.py # SQLModel engine (created at import time)
├── download_manager.py # Async queue, semaphore-based parallelism
├── auth.py # JWT + bcrypt, SQLModel user storage
├── providers.py # ANIME_PROVIDERS, SERIES_PROVIDERS, FILE_HOSTS registries
└── utils.py # sanitize_filename(), is_safe_filename()
templates/ # Jinja2 + HTMX + Alpine.js
static/js/ # Vanilla ES modules (no build step)
tests/ # pytest suite (conftest.py has shared fixtures)
config/ # Runtime JSON files (users, watchlist, sonarr)
alembic/ # DB migrations
```
### File Operations
- Always sanitize filenames: `app.utils.sanitize_filename()`
- Validate paths: `app.utils.is_safe_filename()`
## KEY CONVENTIONS
### Testing
- Use pytest with pytest-asyncio
- Mark tests: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.slow`, `@pytest.mark.network`
- Tests in `test_api.py` are auto-marked as integration, others as unit
- Use fixtures from `tests/conftest.py`
- **URL pipe format**: `video_url|anime_page_url|episode_title` — metadata preserved through download pipeline
- **Module-level init**: `database.py` creates engine on import; `main.py` creates `download_manager` on import
- **Async everywhere**: Always `async/await` for I/O — use `httpx.AsyncClient`, never `requests`
- **Factory pattern**: `get_downloader(url)` routes anime → series → video player → generic
- **Router deps**: `Depends(lambda: download_manager)`, `Depends(get_current_user_from_token)`, `Depends(lambda: templates)`
- **Dual storage**: Some features use JSON files (legacy) + SQLModel tables (newer)
- **Frontend**: No JS build step. HTMX for server interactions, Alpine.js for client state, Plyr.io for video
- **Circular import avoidance**: `episode_checker.py` uses lazy init (`set_download_manager()`)
```python
@pytest.mark.unit
@pytest.mark.asyncio
async def test_download_manager():
manager = DownloadManager(max_parallel=3)
assert manager.max_parallel == 3
## ANTI-PATTERNS (DO NOT)
# Mark slow tests
@pytest.mark.slow
async def test_full_download_flow():
...
- Use sync `requests` — always `httpx.AsyncClient`
- Return `None` for missing URLs from downloaders — raise an exception
- Skip `sanitize_filename()` on extracted filenames — path traversal risk
- Forget `await self.close()` in downloaders — resource leak
- Hardcode User-Agent in individual players — use base class headers
- Use `from X import *` — always explicit imports
- Import `download_manager` from `main.py` in app/ modules — causes circular imports
- Store secrets in `config/*.json` — use `.env`
- Use `as any`, `@ts-ignore` to suppress type errors (if adding TS)
# Mark tests requiring network
@pytest.mark.network
async def test_external_api():
...
```
## TEST CONVENTIONS
### Security
- Never hardcode secrets - use environment variables
- Validate all inputs (URLs, filenames)
- Use HMAC for webhook verification when configured
- Limit CORS origins - never use `*` in production
- `tests/` directory with `conftest.py` for shared fixtures
- Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.network`, `@pytest.mark.slow`
- `asyncio_mode = auto` — async test functions run without explicit marker
- Test naming: `test_<verb>_<noun>` in `Test*` classes
- 300s timeout configured in pytest.ini; `testpaths = tests`
- Legacy test files at project root: `test_watchlist_simple.py`, `test_watchlist_e2e.py`
## Architecture Patterns
## ADDING NEW PROVIDERS
### Three-Tier Downloader Architecture
**Video player**: Create in `app/downloaders/video_players/`, inherit `BaseVideoPlayer`,
implement `can_handle()` + `get_download_link(url, target_filename=None)`, register in
`__init__.py`, add to `FILE_HOSTS` in `providers.py`.
The project uses a three-tier downloader system:
**Anime/series site**: Create in `app/downloaders/anime_sites/` or `series_sites/`, inherit
base class, implement `search_anime()` + `get_episodes()` + `get_anime_metadata()` +
`get_download_link()`, register in `__init__.py`, add to `providers.py`.
1. **Anime Catalogs** (`app/downloaders/anime_sites/`)
- `animesama.py` - Anime-Sama (primary)
- `animeultime.py` - Anime-Ultime
- `nekosama.py` - Neko-Sama
- `vostfree.py` - Vostfree
- `frenchmanga.py` - French-Manga
## NOTES
2. **Series Catalogs** (`app/downloaders/series_sites/`)
- `fs7.py` - French Stream
3. **Video Players** (`app/downloaders/video_players/`)
- `sibnet.py`, `doodstream.py`, `vidmoly.py`, `uqload.py`
- `uptobox.py`, `unfichier.py`, `rapidfile.py`
- `sendvid.py`, `lpayer.py`, `vidzy.py`, `luluv.py`
- `oneupload.py`, `smoothpre.py`
Each tier has a base class and factory pattern. When adding providers:
1. Inherit from appropriate base class (`base.py`)
2. Implement required methods (`search_anime`, `get_episodes`, `get_download_link`)
3. Register in `app/providers.py`
4. Add URL detection patterns
**URL Convention**: Pipe-separated format preserves metadata:
```
video_url|anime_page_url|episode_title
```
### Core Modules
| Module | Purpose |
|--------|---------|
| `app/watchlist.py` | Episode tracking & auto-download |
| `app/auto_download_scheduler.py` | APScheduler for periodic checks |
| `app/episode_checker.py` | New episode detection |
| `app/sonarr_handler.py` | Sonarr webhook integration |
| `app/recommendation_engine.py` | Personalized anime recommendations |
| `app/favorites.py` | User favorites management |
| `app/auth.py` | JWT authentication |
| `app/download_manager.py` | Download queue management |
## Key Files
| File | Purpose |
|------|---------|
| `main.py` | FastAPI app, all API endpoints |
| `app/config.py` | Pydantic Settings configuration |
| `app/download_manager.py` | Download queue & task management |
| `app/utils.py` | `sanitize_filename`, `is_safe_filename` |
| `app/auth.py` | JWT auth, user management |
| `app/providers.py` | Provider definitions & URL detection |
| `app/models/__init__.py` | Core Pydantic models |
| `app/models/watchlist.py` | Watchlist models |
| `app/models/sonarr.py` | Sonarr integration models |
| `app/models/auth.py` | Authentication models |
## Frontend Architecture
### JavaScript Modules (`static/js/`)
| Module | Purpose |
|--------|---------|
| `main.js` | Application entry point |
| `api.js` | API client functions |
| `auth.js` | Authentication handling |
| `tabs.js` | Tab navigation |
| `anime.js` | Anime search & display |
| `anime-details.js` | Anime detail views |
| `watchlist.js` | Watchlist API calls |
| `watchlist-ui.js` | Watchlist UI rendering |
| `downloads.js` | Download management UI |
| `recommendations.js` | Recommendations display |
| `series-search.js` | TV series search |
| `utils.js` | Utility functions |
### Templates (`templates/`)
| Template | Purpose |
|----------|---------|
| `base.html` | Base layout with CSS/JS imports |
| `index.html` | Main SPA interface |
| `login.html` | Login/register page |
| `watchlist.html` | Watchlist management page |
| `player.html` | Video player page |
| `components/` | Reusable HTML components |
## Configuration
- Use `.env` from `.env.example`
- `JWT_SECRET_KEY` must change in production
- Config files stored in `config/`:
- `users.json` - User database
- `watchlist.json` - Watchlist data
- `watchlist_settings.json` - Auto-download settings
- `sonarr.json` - Sonarr integration config
- `sonarr_mappings.json` - Series to anime mappings
## API Endpoints Overview
### Authentication
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Login, get JWT token
- `GET /api/auth/me` - Get current user info
- `POST /api/auth/logout` - Logout (client-side)
### Downloads
- `POST /api/download` - Create download task
- `GET /api/downloads` - List all downloads
- `GET /api/download/{task_id}` - Get download status
- `POST /api/download/{task_id}/pause` - Pause download
- `POST /api/download/{task_id}/resume` - Resume download
- `DELETE /api/download/{task_id}` - Cancel/delete download
- `GET /api/download/{task_id}/file` - Download completed file
### Anime Search & Metadata
- `GET /api/anime/search` - Search across all anime providers
- `GET /api/series/search` - Search TV series providers
- `GET /api/anime/metadata` - Get detailed anime metadata
- `GET /api/anime/episodes` - Get episode list
- `GET /api/anime/seasons` - Get available seasons
- `POST /api/anime/download-season` - Download all episodes
### Watchlist
- `GET /api/watchlist` - List watchlist items
- `POST /api/watchlist` - Add to watchlist
- `PUT /api/watchlist/{item_id}` - Update watchlist item
- `DELETE /api/watchlist/{item_id}` - Remove from watchlist
- `GET /api/watchlist/settings` - Get auto-download settings
- `PUT /api/watchlist/settings` - Update settings
- `POST /api/watchlist/check` - Trigger manual episode check
### Favorites
- `GET /api/favorites` - List favorites
- `POST /api/favorites` - Add to favorites
- `DELETE /api/favorites/{anime_id}` - Remove from favorites
- `POST /api/favorites/toggle` - Toggle favorite status
### Recommendations
- `GET /api/recommendations` - Get personalized recommendations
- `GET /api/releases/latest` - Get latest releases
- `GET /api/releases/seasonal` - Get seasonal anime
### Sonarr Integration
- `POST /api/sonarr/webhook` - Receive Sonarr webhooks
- `GET /api/sonarr/mappings` - List Sonarr mappings
- `POST /api/sonarr/mappings` - Create mapping
- `DELETE /api/sonarr/mappings/{series_id}` - Delete mapping
## Dependencies
### Core
- `fastapi` - Web framework
- `uvicorn` - ASGI server
- `httpx` - Async HTTP client
- `aiohttp` - Alternative HTTP client
- `pydantic` / `pydantic-settings` - Data validation & settings
### Scraping & Parsing
- `beautifulsoup4` - HTML parsing
- `lxml` - XML/HTML parser
- `jieba` - Chinese text segmentation
### Authentication
- `python-jose` - JWT handling
- `passlib[bcrypt]` - Password hashing
### Scheduler
- `apscheduler` - Job scheduling for auto-downloads
### Cryptography
- `pycryptodome` - AES decryption for video players
### Testing
- `pytest` + `pytest-asyncio` - Async test support
- `pytest-cov` - Coverage reporting
- `pytest-mock` - Mocking utilities
- `pytest-timeout` - Test timeout protection
- `pytest-html` - HTML test reports
## CI/CD
### GitHub Actions
This project uses GitHub Actions for continuous integration. The workflow is defined in `.github/workflows/ci.yml`.
**Workflow Features:**
- Runs on push and pull requests to `main` and `dev` branches
- Tests on Python 3.11 and 3.12
- Excludes slow tests by default (`-m "not slow"`)
- Generates HTML coverage reports
- Runs linting with ruff
- Runs type checking with mypy
**Artifacts:**
- Coverage reports are uploaded as artifacts after each run
- Access via the "Actions" tab on GitHub
**Running locally:**
```bash
# Run tests (excluding slow tests)
pytest -m "not slow" --cov=app --cov-report=html
# Run all tests including slow ones
pytest
# Run only unit tests
pytest -m "unit"
# Run only integration tests
pytest -m "integration"
# Run linting
pip install ruff && ruff check app/
# Run type checking
pip install mypy && mypy app/
```
- Python 3.11+, CI tests on 3.11 and 3.12
- No `pyproject.toml` — uses `requirements.txt` with exact version pinning
- `GEMINI.md` and `CLAUDE.md` exist with tool-specific instructions (merge if conflicting)
- French-language project (animes, séries, VOSTFR) but all code and comments in English
- ~20 empty `except:` blocks in downloaders/tests — known tech debt
- `JWT_SECRET_KEY` min 32 chars, default rejected at startup; generate via `Settings.generate_secret()`
- Sub-AGENTS.md files exist in `app/`, `app/routers/`, `app/downloaders/`, `app/models/`,
`app/downloaders/anime_sites/`, `app/downloaders/video_players/` for deeper context
+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`
+29 -17
View File
@@ -33,6 +33,7 @@ class UserManager:
def get_user(self, username: str) -> Optional[UserTable]:
"""Get user by username"""
from app.models.watchlist import WatchlistItemTable # Force registration
with Session(engine) as session:
statement = select(UserTable).where(UserTable.username == username)
return session.exec(statement).first()
@@ -44,7 +45,11 @@ class UserManager:
return session.exec(statement).first()
def create_user(
self, username: str, password: str, email: str = None, full_name: str = None
self,
username: str,
password: str,
email: Optional[str] = None,
full_name: Optional[str] = None,
) -> UserTable:
"""Create a new user"""
with Session(engine) as session:
@@ -68,7 +73,7 @@ class UserManager:
full_name=full_name,
hashed_password=hashed_password,
is_active=True,
created_at=datetime.now()
created_at=datetime.now(),
)
session.add(user)
@@ -191,9 +196,10 @@ REFRESH_TOKENS_FILE = "config/refresh_tokens.json"
def _load_refresh_tokens() -> Dict[str, dict]:
"""Load refresh tokens from file"""
import json
try:
if os.path.exists(REFRESH_TOKENS_FILE):
with open(REFRESH_TOKENS_FILE, 'r', encoding='utf-8') as f:
with open(REFRESH_TOKENS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading refresh tokens: {e}")
@@ -203,9 +209,10 @@ def _load_refresh_tokens() -> Dict[str, dict]:
def _save_refresh_tokens(tokens: Dict[str, dict]):
"""Save refresh tokens to file"""
import json
try:
os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True)
with open(REFRESH_TOKENS_FILE, 'w', encoding='utf-8') as f:
with open(REFRESH_TOKENS_FILE, "w", encoding="utf-8") as f:
json.dump(tokens, f, indent=2, ensure_ascii=False, default=str)
except Exception as e:
logger.error(f"Error saving refresh tokens: {e}")
@@ -216,9 +223,10 @@ def _get_jwt_config() -> dict:
"SECRET_KEY": settings.jwt_secret_key,
"ALGORITHM": settings.jwt_algorithm,
"ACCESS_TOKEN_EXPIRE_MINUTES": settings.access_token_expire_minutes,
"REFRESH_TOKEN_EXPIRE_DAYS": 30
"REFRESH_TOKEN_EXPIRE_DAYS": 30,
}
def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
"""
Create both access and refresh tokens.
@@ -234,29 +242,29 @@ def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
jwt_config = _get_jwt_config()
# Create access token (short-lived)
access_expire = datetime.utcnow() + timedelta(minutes=jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"])
access_expire = datetime.utcnow() + timedelta(
minutes=jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"]
)
access_data = data.copy()
access_data.update({"exp": access_expire, "type": "access"})
access_token = jwt.encode(
access_data,
jwt_config["SECRET_KEY"],
algorithm=jwt_config["ALGORITHM"]
access_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
)
# Create refresh token (long-lived)
refresh_expire = datetime.utcnow() + timedelta(days=jwt_config["REFRESH_TOKEN_EXPIRE_DAYS"])
refresh_expire = datetime.utcnow() + timedelta(
days=jwt_config["REFRESH_TOKEN_EXPIRE_DAYS"]
)
# Generate a unique token ID
token_id = secrets.token_urlsafe(32)
refresh_data = {
"sub": data["sub"],
"token_id": token_id,
"exp": refresh_expire,
"type": "refresh"
"type": "refresh",
}
refresh_token = jwt.encode(
refresh_data,
jwt_config["SECRET_KEY"],
algorithm=jwt_config["ALGORITHM"]
refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
)
# Store refresh token mapping
@@ -265,7 +273,7 @@ def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
"username": data["sub"],
"token_id": token_id,
"created_at": datetime.now().isoformat(),
"expires_at": refresh_expire.isoformat()
"expires_at": refresh_expire.isoformat(),
}
_save_refresh_tokens(refresh_tokens)
@@ -283,7 +291,9 @@ def verify_refresh_token(token: str) -> Optional[str]:
jwt_config = _get_jwt_config()
try:
payload = jwt.decode(token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]])
payload = jwt.decode(
token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]
)
# Verify this is a refresh token
if payload.get("type") != "refresh":
@@ -323,7 +333,9 @@ def revoke_refresh_token(token: str) -> bool:
jwt_config = _get_jwt_config()
try:
payload = jwt.decode(token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]])
payload = jwt.decode(
token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]
)
token_id = payload.get("token_id")
if not token_id:
+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
Handlers for French anime streaming catalogs that provide metadata and episode listings, delegating actual video extraction to video player handlers.
@@ -7,8 +7,8 @@ Handlers for French anime streaming catalogs that provide metadata and episode l
| File | Purpose |
|------|---------|
| `base.py` | Abstract `BaseAnimeSite` class defining the interface all anime sites implement |
| `animesama.py` | Primary provider with dynamic domain switching, multiple video player extraction |
| `base.py` | Abstract `BaseAnimeSite` class defining the interface |
| `animesama.py` | Primary provider dynamic domain switching, multiple video player extraction |
| `nekosama.py` | Neko-Sama / Gupy integration (metadata-only, no direct downloads) |
| `animeultime.py` | Anime-Ultime catalog handler |
| `vostfree.py` | Vostfree catalog handler |
@@ -16,26 +16,26 @@ Handlers for French anime streaming catalogs that provide metadata and episode l
## CONVENTIONS
### Interface Contract
Each site must implement four async methods from `BaseAnimeSite`:
- `can_handle(url: str) -> bool` — URL pattern matching
- `search_anime(query, lang) -> list[dict]` — Returns `{title, url, cover_image}`
- `get_episodes(anime_url, lang) -> list[dict]` — Returns `{episode_number, url, title, host}`
- `get_anime_metadata(anime_url) -> dict` — Returns `{synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status}`
- `get_download_link(url) -> tuple[str, str]` — Returns `(video_player_url, filename)`
**Interface contract** — each site implements from `BaseAnimeSite`:
- `can_handle(url)` — URL pattern matching
- `search_anime(query, lang)``[{title, url, cover_image}]`
- `get_episodes(anime_url, lang)``[{episode_number, url, title, host}]`
- `get_anime_metadata(anime_url)``{synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status}`
- `get_download_link(url)``(video_player_url, filename)`
### Key Patterns
- **Pipe-separated URLs**: `video_url|anime_page_url|episode_title` — preserves context across extraction
- **Language parameter**: `lang="vostfr"` or `"vf"` — controls which episodes to return
- **Video player delegation**: Anime sites return player URLs (vidmoly, sendvid, sibnet, lpayer), not direct downloads
- **Filename generation**: `{anime_name} - S{season} - {episode}.mp4` format
- **HTTP headers**: Browser UA and referer required to avoid blocking
**Key patterns**:
- Pipe-separated URLs: `video_url|anime_page_url|episode_title`
- Language param: `lang="vostfr"` or `"vf"`
- Video player delegation: returns player URLs (vidmoly, sendvid, etc.), NOT direct downloads
- Filename format: `{anime_name} - S{season} - {episode}.mp4`
- Browser UA + referer headers required
### Domain Detection
- `AnimeSamaDownloader` fetches current domain from `anime-sama.pw` dynamically
- Uses fallback chain for video extraction: detected player → cached player → priority list
**Domain detection**: `AnimeSamaDownloader` fetches current domain from `anime-sama.pw` dynamically. Uses fallback chain for video extraction.
### Error Handling
- Raise `Exception` with descriptive message on failure
- Log at appropriate level (`debug` for expected failures, `error` for unexpected)
- Validate extracted URLs with `_test_video_url()` before returning
**Error handling**: Raise `Exception` with descriptive message. Log at `debug` for expected failures, `error` for unexpected. Validate URLs with `_test_video_url()` before returning.
## ANTI-PATTERNS
- Do NOT return direct download URLs from anime sites — return player URLs
- Do NOT skip URL validation — use `_test_video_url()`
- 5 empty `except:` blocks in `animesama.py` — known tech debt, silently swallow failures
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"]
def __init__(self):
super().__init__()
self.id = "anime-ultime"
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
@@ -24,58 +28,79 @@ class AnimeUltimeDownloader(BaseAnimeSite):
final_url = str(response.url)
# Parse the page
soup = BeautifulSoup(response.text, 'lxml')
soup = BeautifulSoup(response.text, "lxml")
# Method 0: Look for og:video meta tag (most reliable for anime-ultime)
og_video = soup.find('meta', property='og:video')
if og_video and og_video.get('content'):
video_url = og_video['content']
if video_url.endswith('.mp4'):
og_video = soup.find("meta", property="og:video")
if og_video and og_video.get("content"):
video_url = og_video["content"]
if video_url.endswith(".mp4"):
filename = self._generate_filename(final_url)
print(f"[ANIME-ULTIME] Found og:video link: {video_url}")
return video_url, filename
# Method 1: Look for direct download links (DDL)
# Anime-Ultime often uses links to file hosts
download_links = soup.find_all('a', href=True)
download_links = soup.find_all("a", href=True)
for link in download_links:
href = link['href']
href = link["href"]
text = link.get_text().lower()
# Look for download buttons/links
if any(keyword in text for keyword in ['télécharger', 'download', 'ddl', 'mega', 'google', 'drive']):
if any(
keyword in text
for keyword in [
"télécharger",
"download",
"ddl",
"mega",
"google",
"drive",
]
):
# Check if it's a direct link or to a file host
if any(host in href.lower() for host in ['mega.nz', 'drive.google.com', 'uptobox.com', '1fichier.com']):
if any(
host in href.lower()
for host in [
"mega.nz",
"drive.google.com",
"uptobox.com",
"1fichier.com",
]
):
filename = self._generate_filename(final_url)
return href, filename
# Method 2: Look for iframe with video player
iframes = soup.find_all('iframe')
iframes = soup.find_all("iframe")
for iframe in iframes:
src = iframe.get('src', '')
if src and any(provider in src for provider in ['video', 'player', 'stream', 'play']):
if src.startswith('http'):
src = iframe.get("src", "")
if src and any(
provider in src
for provider in ["video", "player", "stream", "play"]
):
if src.startswith("http"):
filename = self._generate_filename(final_url)
return src, filename
# Method 3: Look for video tags
videos = soup.find_all('video')
videos = soup.find_all("video")
for video in videos:
src = video.get('src', '')
src = video.get("src", "")
if src:
filename = self._generate_filename(final_url)
return src, filename
# Check source tags
sources = video.find_all('source')
sources = video.find_all("source")
for source in sources:
src = source.get('src', '')
src = source.get("src", "")
if src:
filename = self._generate_filename(final_url)
return src, filename
# Method 4: Look in scripts for video URLs
scripts = soup.find_all('script')
scripts = soup.find_all("script")
for script in scripts:
if script.string:
# Look for common video patterns
@@ -91,26 +116,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
matches = re.findall(pattern, script.string)
for match in matches:
# Clean up escaped characters
match = match.replace('\\/', '/').replace('\\', '')
if any(ext in match for ext in ['mp4', 'm3u8', 'mkv']):
match = match.replace("\\/", "/").replace("\\", "")
if any(ext in match for ext in ["mp4", "m3u8", "mkv"]):
filename = self._generate_filename(final_url)
return match, filename
# Look for anime-ultime specific patterns
# They sometimes store links in JavaScript variables
ddl_match = re.search(r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string)
ddl_match = re.search(
r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string
)
if ddl_match:
ddl_url = ddl_match.group(1)
if ddl_url.startswith('http'):
if ddl_url.startswith("http"):
filename = self._generate_filename(final_url)
return ddl_url, filename
# Method 5: Look for links with specific classes or IDs
# Anime-Ultime might use specific class names for download links
potential_links = soup.find_all('a', class_=re.compile(r'download|ddl|episode', re.I))
potential_links = soup.find_all(
"a", class_=re.compile(r"download|ddl|episode", re.I)
)
for link in potential_links:
href = link.get('href', '')
if href and href.startswith('http'):
href = link.get("href", "")
if href and href.startswith("http"):
filename = self._generate_filename(final_url)
return href, filename
@@ -132,36 +161,38 @@ class AnimeUltimeDownloader(BaseAnimeSite):
episode = "01"
# Format: info-0-1/EPISODE_ID or info-0-1/EPISODE_ID/NAME-EP-vostfr
if 'info-0-1/' in url:
if "info-0-1/" in url:
# Extract episode ID
ep_match = re.search(r'info-0-1/(\d+)', url)
ep_match = re.search(r"info-0-1/(\d+)", url)
if ep_match:
ep_id = ep_match.group(1)
# Try to get anime name from URL path
name_match = re.search(r'info-0-1/\d+/([^/]+)', url)
name_match = re.search(r"info-0-1/\d+/([^/]+)", url)
if name_match:
raw_name = name_match.group(1)
# Extract episode number
ep_num_match = re.search(r'-(\d+)-vostfr$', raw_name, re.I)
ep_num_match = re.search(r"-(\d+)-vostfr$", raw_name, re.I)
if ep_num_match:
episode = ep_num_match.group(1).zfill(2)
# Remove episode number and suffix from name
anime_name = re.sub(r'-\d+-vostfr$', '', raw_name, flags=re.I).replace('-', ' ')
anime_name = re.sub(
r"-\d+-vostfr$", "", raw_name, flags=re.I
).replace("-", " ")
else:
# Just use the ID
anime_name = f"Episode {ep_id}"
else:
anime_name = f"Episode {ep_id}"
elif 'file-0-1/' in url:
elif "file-0-1/" in url:
# Extract from file-0-1/ID-NAME format
file_match = re.search(r'file-0-1/\d+-(.+)$', url)
file_match = re.search(r"file-0-1/\d+-(.+)$", url)
if file_match:
anime_name = file_match.group(1).replace('-', ' ')
anime_name = file_match.group(1).replace("-", " ")
# Sanitize filename
anime_name = anime_name.replace('/', ' ').strip()
anime_name = anime_name.replace("/", " ").strip()
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
@@ -173,30 +204,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
try:
print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
soup = BeautifulSoup(response.text, "lxml")
metadata = {
'synopsis': None,
'genres': [],
'rating': None,
'release_year': None,
'studio': None,
'poster_image': None,
'banner_image': None,
'total_episodes': None,
'status': None,
'alternative_titles': []
"synopsis": None,
"genres": [],
"rating": None,
"release_year": None,
"studio": None,
"poster_image": None,
"banner_image": None,
"total_episodes": None,
"status": None,
"alternative_titles": [],
}
# Extract synopsis
synopsis_selectors = [
'div.synopsis',
'div.description',
"div.synopsis",
"div.description",
'div[class*="synopsis"]',
'div[class*="synopsis"]',
'p.synopsis',
'.info',
'div.texte'
"p.synopsis",
".info",
"div.texte",
]
for selector in synopsis_selectors:
@@ -204,68 +235,73 @@ class AnimeUltimeDownloader(BaseAnimeSite):
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
if len(synopsis) > 50:
metadata['synopsis'] = synopsis
metadata["synopsis"] = synopsis
break
# Extract genres from meta tags and page content
page_text = soup.get_text()
# Look for genre in meta tags
genre_meta = soup.find('meta', property='genre') or soup.find('meta', attrs={'name': 'genre'})
genre_meta = soup.find("meta", property="genre") or soup.find(
"meta", attrs={"name": "genre"}
)
if genre_meta:
genres_text = genre_meta.get('content', '')
genres_text = genre_meta.get("content", "")
if genres_text:
metadata['genres'] = [g.strip() for g in genres_text.split(',')]
metadata["genres"] = [g.strip() for g in genres_text.split(",")]
# Try to find genre links
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type|cat', re.I))
genre_links = soup.find_all(
"a", href=re.compile(r"genre|tag|type|cat", re.I)
)
if genre_links:
for link in genre_links[:5]:
genre = link.get_text(strip=True)
if genre and genre not in metadata['genres']:
metadata['genres'].append(genre)
if genre and genre not in metadata["genres"]:
metadata["genres"].append(genre)
# Extract rating
rating_selectors = [
'span.rating',
'div.rating',
'span.score',
'div.note',
'.rating'
"span.rating",
"div.rating",
"span.score",
"div.note",
".rating",
]
for selector in rating_selectors:
rating_elem = soup.select_one(selector)
if rating_elem:
rating_text = rating_elem.get_text(strip=True)
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
if rating_match:
metadata['rating'] = f"{rating_match.group(1)}/10"
metadata["rating"] = f"{rating_match.group(1)}/10"
break
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*5', rating_text)
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*5", rating_text)
if rating_match:
rating_val = float(rating_match.group(1)) * 2
metadata['rating'] = f"{rating_val:.1f}/10"
metadata["rating"] = f"{rating_val:.1f}/10"
break
# Extract release year
year_match = re.search(r'\b(19\d{2}|20\d{2})\b', page_text)
year_match = re.search(r"\b(19\d{2}|20\d{2})\b", page_text)
if year_match:
import datetime
current_year = datetime.datetime.now().year + 2
year = int(year_match.group(1))
if 1950 <= year <= current_year:
metadata['release_year'] = year
metadata["release_year"] = year
# Extract poster image from og:image
og_image = soup.find('meta', property='og:image')
og_image = soup.find("meta", property="og:image")
if og_image:
metadata['poster_image'] = og_image.get('content')
metadata["poster_image"] = og_image.get("content")
# Extract total episodes
episodes_count = len(await self.get_episodes(anime_url))
if episodes_count > 0:
metadata['total_episodes'] = episodes_count
metadata["total_episodes"] = episodes_count
print(f"[ANIME-ULTIME] Extracted metadata: {metadata}")
return metadata
@@ -274,7 +310,9 @@ class AnimeUltimeDownloader(BaseAnimeSite):
print(f"[ANIME-ULTIME] Error extracting metadata: {e}")
return {}
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
async def search_anime(
self, query: str, lang: str = "vostfr", include_metadata: bool = False
) -> list[dict]:
"""
Search for anime on anime-ultime
Returns list of anime with title, url, and cover image
@@ -286,27 +324,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
"""
try:
import time
start = time.time()
print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...")
# Anime-Ultime uses POST for search
search_url = "https://www.anime-ultime.net/search-0-1"
response = await self.client.post(search_url, data={'search': query})
soup = BeautifulSoup(response.text, 'lxml')
response = await self.client.post(search_url, data={"search": query})
soup = BeautifulSoup(response.text, "lxml")
elapsed = time.time() - start
print(f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s")
print(
f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s"
)
results = []
# Look for search result links - better parsing
# Search results use file-0-1/ pattern, not info-
search_results = soup.find_all('a', href=re.compile(r'file-0-1/'))
search_results = soup.find_all("a", href=re.compile(r"file-0-1/"))
seen_urls = set()
for result in search_results[:10]: # Limit to 10 results
href = result.get('href', '')
href = result.get("href", "")
raw_title = result.get_text().strip()
# Skip if no href
@@ -322,40 +363,44 @@ class AnimeUltimeDownloader(BaseAnimeSite):
better_title = raw_title
# If raw_title is just "Télécharger" or similar, try to find better title
if len(raw_title) < 5 or raw_title.lower() in ['télécharger', 'download', 'ddl']:
if len(raw_title) < 5 or raw_title.lower() in [
"télécharger",
"download",
"ddl",
]:
# Try to extract from URL (file-0-1/ID-Title format)
url_match = re.search(r'file-0-1/\d+-(.+)$', href)
url_match = re.search(r"file-0-1/\d+-(.+)$", href)
if url_match:
better_title = url_match.group(1).replace('-', ' ').title()
better_title = url_match.group(1).replace("-", " ").title()
# If still no good title, look at parent/row elements
if len(better_title) < 5:
# Check parent row (table structure)
row = result.find_parent(['tr', 'td', 'div'])
row = result.find_parent(["tr", "td", "div"])
if row:
# Look for text in the row that's not the link text
row_text = row.get_text().strip()
# Remove the link text from row text
if raw_title in row_text:
row_text = row_text.replace(raw_title, '').strip()
row_text = row_text.replace(raw_title, "").strip()
if len(row_text) > 5 and len(row_text) < 100:
better_title = row_text
# Make URL absolute
if not href.startswith('http'):
if not href.startswith("http"):
href = urljoin("https://www.anime-ultime.net/", href)
result_item = {
'title': better_title,
'url': href,
'type': 'search_result',
'metadata': None
"title": better_title,
"url": href,
"type": "search_result",
"metadata": None,
}
# Fetch metadata if requested
if include_metadata:
metadata = await self.get_anime_metadata(href)
result_item['metadata'] = metadata
result_item["metadata"] = metadata
results.append(result_item)
@@ -373,27 +418,27 @@ class AnimeUltimeDownloader(BaseAnimeSite):
"""
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
soup = BeautifulSoup(response.text, "lxml")
episodes = []
# Look for episode links - anime-ultime uses info-XXXXX-Name-XX-vostfr format
# The URL pattern is info-0-1/ID-Anime-Name-XX-vostfr where XX is episode number
episode_links = soup.find_all('a', href=re.compile(r'info-0-1/\d+'))
episode_links = soup.find_all("a", href=re.compile(r"info-0-1/\d+"))
for link in episode_links:
href = link.get('href', '')
href = link.get("href", "")
text = link.get_text().strip()
# Extract episode number from URL pattern
# Matches: info-0-1/30200/Naruto-OAV-01-vostfr
match = re.search(r'-(\d+)-vostfr$', href, re.I)
match = re.search(r"-(\d+)-vostfr$", href, re.I)
if not match:
# Try other patterns
match = re.search(r'Episode[-\s]?(\d+)', href, re.I)
match = re.search(r"Episode[-\s]?(\d+)", href, re.I)
if not match:
# Try to extract from text
match = re.search(r'(\d+)', text)
match = re.search(r"(\d+)", text)
if match:
episode_num = match.group(1).zfill(2) # Pad with zero
@@ -401,32 +446,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
# Extract the episode ID from href and build correct URL
# href might be "info-0-1/30200" or "info-0-1/30200/..."
# We need: https://www.anime-ultime.net/info-0-1/30200
ep_id_match = re.search(r'info-0-1/(\d+)', href)
ep_id_match = re.search(r"info-0-1/(\d+)", href)
if ep_id_match:
ep_id = ep_id_match.group(1)
# Build the correct episode URL
episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}"
else:
# Fallback to making URL absolute
if not href.startswith('http'):
if not href.startswith("http"):
href = urljoin(anime_url, href)
episode_url = href
episodes.append({
'episode': episode_num,
'url': episode_url,
'title': text
})
episodes.append(
{"episode": episode_num, "url": episode_url, "title": text}
)
# Remove duplicates and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep['episode'] not in seen:
seen.add(ep['episode'])
if ep["episode"] not in seen:
seen.add(ep["episode"])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x['episode']))
unique_episodes.sort(key=lambda x: int(x["episode"]))
return unique_episodes
+90 -82
View File
@@ -1,4 +1,5 @@
"""French-Manga.net anime streaming site downloader"""
from .base import BaseAnimeSite
from bs4 import BeautifulSoup
import re
@@ -17,11 +18,12 @@ class FrenchMangaDownloader(BaseAnimeSite):
"french-manga.net",
"w16.french-manga.net",
"w15.french-manga.net",
"www.french-manga.net"
"www.french-manga.net",
]
def __init__(self):
super().__init__()
self.id = "french-manga"
self.base_url = "https://w16.french-manga.net"
def can_handle(self, url: str) -> bool:
@@ -29,9 +31,7 @@ class FrenchMangaDownloader(BaseAnimeSite):
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def search_anime(
self,
query: str,
lang: str = "vostfr"
self, query: str, lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Search for anime on French-Manga.
@@ -47,46 +47,50 @@ class FrenchMangaDownloader(BaseAnimeSite):
# French-Manga uses a search endpoint
search_url = f"{self.base_url}/index.php?do=search"
params = {
'do': 'search',
'subaction': 'search',
'story': query,
'x': '0',
'y': '0'
"do": "search",
"subaction": "search",
"story": query,
"x": "0",
"y": "0",
}
response = await self.client.post(search_url, data=params)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
results = []
# Look for search results in article or story classes
for item in soup.find_all('article', class_=lambda x: x and 'story' in x.lower()):
title_elem = item.find(['h2', 'h3', 'h4'])
link_elem = item.find('a', href=True)
img_elem = item.find('img')
for item in soup.find_all(
"article", class_=lambda x: x and "story" in x.lower()
):
title_elem = item.find(["h2", "h3", "h4"])
link_elem = item.find("a", href=True)
img_elem = item.find("img")
if title_elem and link_elem:
title = title_elem.get_text(strip=True)
url = link_elem['href']
url = link_elem["href"]
# Ensure absolute URL
if url.startswith('/'):
if url.startswith("/"):
url = self.base_url + url
cover_image = ""
if img_elem and img_elem.get('src'):
cover_image = img_elem['src']
if cover_image.startswith('/'):
if img_elem and img_elem.get("src"):
cover_image = img_elem["src"]
if cover_image.startswith("/"):
cover_image = self.base_url + cover_image
results.append({
'title': title,
'url': url,
'cover_image': cover_image,
'lang': lang
})
results.append(
{
"title": title,
"url": url,
"cover_image": cover_image,
"lang": lang,
}
)
logger.info(f"Found {len(results)} anime results for query: {query}")
return results
@@ -96,9 +100,7 @@ class FrenchMangaDownloader(BaseAnimeSite):
return []
async def get_episodes(
self,
anime_url: str,
lang: str = "vostfr"
self, anime_url: str, lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Get episode list for an anime.
@@ -115,34 +117,36 @@ class FrenchMangaDownloader(BaseAnimeSite):
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
episodes = []
# Look for episode links (typically in a list or table)
# French-Manga usually has episode links in <a> tags with episode numbers
for link in soup.find_all('a', href=True):
href = link['href']
for link in soup.find_all("a", href=True):
href = link["href"]
text = link.get_text(strip=True)
# Pattern: Episode links usually contain "episode" or numbers
if re.search(r'episode?\s*\d+', text.lower()):
episode_num = re.search(r'(\d+)', text)
if re.search(r"episode?\s*\d+", text.lower()):
episode_num = re.search(r"(\d+)", text)
if episode_num:
episode_number = int(episode_num.group(1))
# Ensure absolute URL
if href.startswith('/'):
if href.startswith("/"):
href = self.base_url + href
episodes.append({
'episode_number': episode_number,
'url': href,
'title': text,
'host': 'french-manga'
})
episodes.append(
{
"episode_number": episode_number,
"url": href,
"title": text,
"host": "french-manga",
}
)
# Sort by episode number
episodes.sort(key=lambda x: x['episode_number'])
episodes.sort(key=lambda x: x["episode_number"])
logger.info(f"Found {len(episodes)} episodes for {anime_url}")
return episodes
@@ -166,31 +170,33 @@ class FrenchMangaDownloader(BaseAnimeSite):
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
# Extract title
title = ""
title_elem = soup.find('h1') or soup.find('h2', class_='title')
title_elem = soup.find("h1") or soup.find("h2", class_="title")
if title_elem:
title = title_elem.get_text(strip=True)
# Extract synopsis
synopsis = ""
synopsis_elem = soup.find('div', class_=lambda x: x and 'story' in x.lower())
synopsis_elem = soup.find(
"div", class_=lambda x: x and "story" in x.lower()
)
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
# Extract cover image
poster_image = ""
img_elem = soup.find('img', class_=lambda x: x and 'poster' in x.lower())
if img_elem and img_elem.get('src'):
poster_image = img_elem['src']
if poster_image.startswith('/'):
img_elem = soup.find("img", class_=lambda x: x and "poster" in x.lower())
if img_elem and img_elem.get("src"):
poster_image = img_elem["src"]
if poster_image.startswith("/"):
poster_image = self.base_url + poster_image
# Extract genres
genres = []
genre_links = soup.find_all('a', href=re.compile(r'/xfsearch/.*genre/'))
genre_links = soup.find_all("a", href=re.compile(r"/xfsearch/.*genre/"))
for link in genre_links[:10]: # Limit to 10 genres
genre = link.get_text(strip=True)
if genre:
@@ -198,36 +204,38 @@ class FrenchMangaDownloader(BaseAnimeSite):
# Extract rating (if available)
rating = ""
rating_elem = soup.find(['span', 'div'], class_=lambda x: x and 'rating' in x.lower())
rating_elem = soup.find(
["span", "div"], class_=lambda x: x and "rating" in x.lower()
)
if rating_elem:
rating = rating_elem.get_text(strip=True)
return {
'title': title,
'synopsis': synopsis,
'genres': genres,
'rating': rating,
'release_year': '',
'studio': '',
'poster_image': poster_image,
'total_episodes': len(await self.get_episodes(anime_url)),
'status': '',
'languages': ['vf', 'vostfr']
"title": title,
"synopsis": synopsis,
"genres": genres,
"rating": rating,
"release_year": "",
"studio": "",
"poster_image": poster_image,
"total_episodes": len(await self.get_episodes(anime_url)),
"status": "",
"languages": ["vf", "vostfr"],
}
except Exception as e:
logger.error(f"Error getting anime metadata: {e}")
return {
'title': '',
'synopsis': '',
'genres': [],
'rating': '',
'release_year': '',
'studio': '',
'poster_image': '',
'total_episodes': 0,
'status': '',
'languages': ['vf', 'vostfr']
"title": "",
"synopsis": "",
"genres": [],
"rating": "",
"release_year": "",
"studio": "",
"poster_image": "",
"total_episodes": 0,
"status": "",
"languages": ["vf", "vostfr"],
}
async def get_download_link(self, url: str) -> tuple[str, str]:
@@ -248,20 +256,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
# Look for iframe or video player
iframe = soup.find('iframe', src=True)
iframe = soup.find("iframe", src=True)
if iframe:
video_url = iframe['src']
video_url = iframe["src"]
else:
# Look for video tag directly
video = soup.find('video', src=True)
video = soup.find("video", src=True)
if video:
video_url = video['src']
video_url = video["src"]
else:
# Try to find in script tags
scripts = soup.find_all('script')
scripts = soup.find_all("script")
for script in scripts:
if script.string:
# Look for iframe or video URLs in JavaScript
@@ -274,20 +282,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
if match:
video_url = match.group(1)
break
if 'video_url' in locals():
if "video_url" in locals():
break
if 'video_url' not in locals():
if "video_url" not in locals():
raise ValueError("Could not find video player URL")
# Ensure absolute URL
if video_url.startswith('//'):
video_url = 'https:' + video_url
elif video_url.startswith('/'):
if video_url.startswith("//"):
video_url = "https:" + video_url
elif video_url.startswith("/"):
video_url = self.base_url + video_url
# Extract episode title
title_elem = soup.find('h1') or soup.find('h2')
title_elem = soup.find("h1") or soup.find("h2")
episode_title = title_elem.get_text(strip=True) if title_elem else "Episode"
episode_title = sanitize_filename(episode_title)
+112 -80
View File
@@ -13,12 +13,25 @@ class NekoSamaDownloader(BaseAnimeSite):
This provider can search and get metadata but cannot provide direct download links.
"""
BASE_DOMAINS = ["neko-sama.org", "www.neko-sama.org", "neko-sama.fr", "nekosama.fr", "www.gupy.fr", "gupy.fr"]
BASE_DOMAINS = [
"neko-sama.org",
"www.neko-sama.org",
"neko-sama.fr",
"nekosama.fr",
"www.gupy.fr",
"gupy.fr",
]
def __init__(self):
super().__init__()
self.id = "neko-sama"
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str, target_filename: Optional[str] = None) -> tuple[str, str]:
async def get_download_link(
self, url: str, target_filename: Optional[str] = None
) -> tuple[str, str]:
"""
Extract download link from neko-sama URL.
@@ -27,59 +40,67 @@ class NekoSamaDownloader(BaseAnimeSite):
"""
try:
# Check if this is a Gupy URL
if 'gupy.fr' in url or 'neko-sama.org' in url:
if "gupy.fr" in url or "neko-sama.org" in url:
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, 'lxml')
soup = BeautifulSoup(response.text, "lxml")
# Look for streaming platform links
streaming_links = []
for link in soup.find_all('a', href=True):
href = link.get('href', '')
if '/out/' in href:
for link in soup.find_all("a", href=True):
href = link.get("href", "")
if "/out/" in href:
text = link.get_text(strip=True)
if text and 'Regarder' in text:
if text and "Regarder" in text:
streaming_links.append(f"{text}: {href}")
if streaming_links:
title_elem = soup.find('h1') or soup.find('title')
title = title_elem.get_text(strip=True).split('|')[0].strip() if title_elem else "Unknown"
info = "Available streaming platforms:\n" + "\n".join(streaming_links[:5])
title_elem = soup.find("h1") or soup.find("title")
title = (
title_elem.get_text(strip=True).split("|")[0].strip()
if title_elem
else "Unknown"
)
info = "Available streaming platforms:\n" + "\n".join(
streaming_links[:5]
)
filename = target_filename or f"{title}_streaming_info.txt"
return info, filename
raise Exception("No streaming links found - Gupy is a legal streaming search, not a video host")
raise Exception(
"No streaming links found - Gupy is a legal streaming search, not a video host"
)
# Legacy: try original method for other URLs
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, 'lxml')
soup = BeautifulSoup(response.text, "lxml")
# Method 1: Look for iframes with video
iframes = soup.find_all('iframe')
iframes = soup.find_all("iframe")
for iframe in iframes:
src = iframe.get('src', '')
if src and any(p in src for p in ['video', 'player', 'stream']):
if not src.startswith('http'):
src = iframe.get("src", "")
if src and any(p in src for p in ["video", "player", "stream"]):
if not src.startswith("http"):
src = urljoin(str(response.url), src)
filename = self._generate_filename(str(response.url))
return src, filename
# Method 2: Look for video tags
videos = soup.find_all('video')
videos = soup.find_all("video")
for video in videos:
src = video.get('src') or video.get('data-src')
src = video.get("src") or video.get("data-src")
if src:
filename = self._generate_filename(str(response.url))
return src, filename
sources = video.find_all('source')
sources = video.find_all("source")
for source in sources:
src = source.get('src', '')
src = source.get("src", "")
if src:
filename = self._generate_filename(str(response.url))
return src, filename
# Method 3: Look in scripts
scripts = soup.find_all('script')
scripts = soup.find_all("script")
for script in scripts:
if script.string:
patterns = [
@@ -90,24 +111,26 @@ class NekoSamaDownloader(BaseAnimeSite):
for pattern in patterns:
matches = re.findall(pattern, script.string)
for match in matches:
match = match.replace('\\/', '/')
if any(ext in match for ext in ['mp4', 'm3u8']):
match = match.replace("\\/", "/")
if any(ext in match for ext in ["mp4", "m3u8"]):
filename = self._generate_filename(str(response.url))
return match, filename
raise Exception("Could not find video link - Neko-Sama/Gupy does not host video content")
raise Exception(
"Could not find video link - Neko-Sama/Gupy does not host video content"
)
except Exception as e:
raise Exception(f"Error extracting NekoSama link: {str(e)}")
def _generate_filename(self, url: str) -> str:
parts = url.split('/')
parts = url.split("/")
anime_name = "anime"
episode = "1"
for i, part in enumerate(parts):
if 'episode' in part.lower():
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
if "episode" in part.lower():
match = re.search(r"episode[-\s]*(\d+)", part, re.I)
if match:
episode = match.group(1)
@@ -118,31 +141,31 @@ class NekoSamaDownloader(BaseAnimeSite):
"""Get list of episodes for an anime."""
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
soup = BeautifulSoup(response.text, "lxml")
episodes = []
# Try to find episode links
episode_links = soup.find_all('a', href=re.compile(r'episode'))
episode_links = soup.find_all("a", href=re.compile(r"episode"))
for link in episode_links:
href = link.get('href', '')
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
href = link.get("href", "")
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
if match:
episode_num = match.group(1)
if not href.startswith('http'):
if not href.startswith("http"):
href = urljoin(anime_url, href)
episodes.append({'episode': episode_num, 'url': href})
episodes.append({"episode": episode_num, "url": href})
# Deduplicate and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep['episode'] not in seen:
seen.add(ep['episode'])
if ep["episode"] not in seen:
seen.add(ep["episode"])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x['episode']))
unique_episodes.sort(key=lambda x: int(x["episode"]))
return unique_episodes
except Exception as e:
@@ -153,70 +176,70 @@ class NekoSamaDownloader(BaseAnimeSite):
try:
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
soup = BeautifulSoup(response.text, "lxml")
metadata = {
'synopsis': None,
'genres': [],
'rating': None,
'release_year': None,
'studio': None,
'poster_image': None,
'banner_image': None,
'total_episodes': None,
'status': None,
'alternative_titles': []
"synopsis": None,
"genres": [],
"rating": None,
"release_year": None,
"studio": None,
"poster_image": None,
"banner_image": None,
"total_episodes": None,
"status": None,
"alternative_titles": [],
}
# Extract title and year from h1
title_elem = soup.find('h1')
title_elem = soup.find("h1")
if title_elem:
title_text = title_elem.get_text(strip=True)
# Extract year from title like "Naruto (2002)"
year_match = re.search(r'\((\d{4})\)', title_text)
year_match = re.search(r"\((\d{4})\)", title_text)
if year_match:
metadata['release_year'] = int(year_match.group(1))
metadata["release_year"] = int(year_match.group(1))
# Extract synopsis - Gupy shows it as paragraphs
synopsis_elem = soup.find('p')
synopsis_elem = soup.find("p")
if synopsis_elem:
text = synopsis_elem.get_text(strip=True)
if len(text) > 50:
metadata['synopsis'] = text
metadata["synopsis"] = text
# Extract genres from meta tags or links
genre_links = soup.find_all('a', href=re.compile(r'serie-|genre|tag'))
genre_links = soup.find_all("a", href=re.compile(r"serie-|genre|tag"))
if genre_links:
genres = []
for link in genre_links[:5]:
text = link.get_text(strip=True)
if text and '/' not in text and len(text) < 30:
if text and "/" not in text and len(text) < 30:
genres.append(text)
metadata['genres'] = genres
metadata["genres"] = genres
# Extract rating from percentage
rating_elem = soup.find(string=re.compile(r'\d+(\.\d+)?%'))
rating_elem = soup.find(string=re.compile(r"\d+(\.\d+)?%"))
if rating_elem:
match = re.search(r'(\d+(\.\d+)?)%', rating_elem)
match = re.search(r"(\d+(\.\d+)?)%", rating_elem)
if match:
rating = float(match.group(1)) / 10
metadata['rating'] = f"{rating:.1f}/10"
metadata["rating"] = f"{rating:.1f}/10"
# Extract poster image
poster_elem = soup.find('img', src=re.compile(r'poster|poster'))
poster_elem = soup.find("img", src=re.compile(r"poster|poster"))
if poster_elem:
metadata['poster_image'] = poster_elem.get('src')
metadata["poster_image"] = poster_elem.get("src")
# Extract episode count from page text
page_text = soup.get_text()
ep_match = re.search(r'(\d+)\s*episodes?', page_text, re.I)
ep_match = re.search(r"(\d+)\s*episodes?", page_text, re.I)
if ep_match:
metadata['total_episodes'] = int(ep_match.group(1))
metadata["total_episodes"] = int(ep_match.group(1))
# Extract studio/director
director_elem = soup.find('a', href=re.compile(r'person|réalisé'))
director_elem = soup.find("a", href=re.compile(r"person|réalisé"))
if director_elem:
metadata['studio'] = director_elem.get_text(strip=True)
metadata["studio"] = director_elem.get_text(strip=True)
print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
return metadata
@@ -225,16 +248,19 @@ class NekoSamaDownloader(BaseAnimeSite):
print(f"[NEKO-SAMA] Error extracting metadata: {e}")
return {}
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
async def search_anime(
self, query: str, lang: str = "vostfr", include_metadata: bool = False
) -> list[dict]:
"""Search for anime on neko-sama (uses Gupy backend)."""
try:
import time
from html import unescape
start = time.time()
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
# Neko-Sama now uses Gupy - try the direct URL pattern
search_slug = query.lower().replace(' ', '-')
search_slug = query.lower().replace(" ", "-")
search_urls = [
f"https://www.gupy.fr/series/{search_slug}/",
f"https://neko-sama.org/series/{search_slug}/",
@@ -250,34 +276,40 @@ class NekoSamaDownloader(BaseAnimeSite):
print(f"[NEKO-SAMA] Found anime at {final_url}")
# Extract title from page
soup = BeautifulSoup(response.text, 'lxml')
title_elem = soup.find('h1') or soup.find('title')
title = unescape(title_elem.get_text(strip=True)) if title_elem else query
soup = BeautifulSoup(response.text, "lxml")
title_elem = soup.find("h1") or soup.find("title")
title = (
unescape(title_elem.get_text(strip=True))
if title_elem
else query
)
# Clean up title
title = title.split('|')[0].split('-')[0].strip()
title = title.split("|")[0].split("-")[0].strip()
result = {
'title': title,
'url': final_url,
'cover_image': None,
'type': 'direct',
'metadata': None
"title": title,
"url": final_url,
"cover_image": None,
"type": "direct",
"metadata": None,
}
# Try to get poster
poster = soup.find('img', src=re.compile(r'poster'))
poster = soup.find("img", src=re.compile(r"poster"))
if poster:
result['cover_image'] = poster.get('src')
result["cover_image"] = poster.get("src")
if include_metadata:
metadata = await self.get_anime_metadata(final_url)
result['metadata'] = metadata
result["metadata"] = metadata
results.append(result)
break
elapsed = time.time() - start
print(f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results")
print(
f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results"
)
return results
except Exception as e:
+78 -63
View File
@@ -9,6 +9,10 @@ class VostfreeDownloader(BaseAnimeSite):
BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"]
def __init__(self):
super().__init__()
self.id = "vostfree"
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
@@ -16,35 +20,35 @@ class VostfreeDownloader(BaseAnimeSite):
"""Extract download link from vostfree URL"""
try:
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, 'lxml')
soup = BeautifulSoup(response.text, "lxml")
# Method 1: Look for iframe players
iframes = soup.find_all('iframe')
iframes = soup.find_all("iframe")
for iframe in iframes:
src = iframe.get('src', '')
if src and any(p in src for p in ['player', 'video', 'stream']):
if not src.startswith('http'):
src = iframe.get("src", "")
if src and any(p in src for p in ["player", "video", "stream"]):
if not src.startswith("http"):
src = urljoin(str(response.url), src)
filename = self._generate_filename(str(response.url))
return src, filename
# Method 2: Look for video tags
videos = soup.find_all('video')
videos = soup.find_all("video")
for video in videos:
src = video.get('src')
src = video.get("src")
if src:
filename = self._generate_filename(str(response.url))
return src, filename
sources = video.find_all('source')
sources = video.find_all("source")
for source in sources:
src = source.get('src', '')
if src and any(ext in src for ext in ['mp4', 'm3u8']):
src = source.get("src", "")
if src and any(ext in src for ext in ["mp4", "m3u8"]):
filename = self._generate_filename(str(response.url))
return src, filename
# Method 3: Look in scripts
scripts = soup.find_all('script')
scripts = soup.find_all("script")
for script in scripts:
if script.string:
patterns = [
@@ -56,8 +60,8 @@ class VostfreeDownloader(BaseAnimeSite):
for pattern in patterns:
matches = re.findall(pattern, script.string)
for match in matches:
match = match.replace('\\/', '/')
if any(ext in match for ext in ['mp4', 'm3u8']):
match = match.replace("\\/", "/")
if any(ext in match for ext in ["mp4", "m3u8"]):
filename = self._generate_filename(str(response.url))
return match, filename
@@ -67,12 +71,12 @@ class VostfreeDownloader(BaseAnimeSite):
raise Exception(f"Error extracting Vostfree link: {str(e)}")
def _generate_filename(self, url: str) -> str:
parts = url.split('/')
parts = url.split("/")
anime_name = "anime"
episode = "1"
for part in parts:
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
match = re.search(r"episode[-\s]*(\d+)", part, re.I)
if match:
episode = match.group(1)
@@ -82,30 +86,30 @@ class VostfreeDownloader(BaseAnimeSite):
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
soup = BeautifulSoup(response.text, "lxml")
episodes = []
episode_links = soup.find_all('a', href=re.compile(r'episode', re.I))
episode_links = soup.find_all("a", href=re.compile(r"episode", re.I))
for link in episode_links:
href = link.get('href', '')
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
href = link.get("href", "")
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
if match:
episode_num = match.group(1)
if not href.startswith('http'):
if not href.startswith("http"):
href = urljoin(anime_url, href)
episodes.append({'episode': episode_num, 'url': href})
episodes.append({"episode": episode_num, "url": href})
# Deduplicate and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep['episode'] not in seen:
seen.add(ep['episode'])
if ep["episode"] not in seen:
seen.add(ep["episode"])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x['episode']))
unique_episodes.sort(key=lambda x: int(x["episode"]))
return unique_episodes
except Exception as e:
@@ -119,29 +123,29 @@ class VostfreeDownloader(BaseAnimeSite):
try:
print(f"[VOSTFREE] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
soup = BeautifulSoup(response.text, "lxml")
metadata = {
'synopsis': None,
'genres': [],
'rating': None,
'release_year': None,
'studio': None,
'poster_image': None,
'banner_image': None,
'total_episodes': None,
'status': None,
'alternative_titles': []
"synopsis": None,
"genres": [],
"rating": None,
"release_year": None,
"studio": None,
"poster_image": None,
"banner_image": None,
"total_episodes": None,
"status": None,
"alternative_titles": [],
}
# Extract synopsis
synopsis_selectors = [
'div.synopsis',
'div.description',
"div.synopsis",
"div.description",
'div[class*="synopsis"]',
'div[class*="desc"]',
'p.synopsis',
'.anime-synopsis'
"p.synopsis",
".anime-synopsis",
]
for selector in synopsis_selectors:
@@ -149,57 +153,65 @@ class VostfreeDownloader(BaseAnimeSite):
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
if len(synopsis) > 50:
metadata['synopsis'] = synopsis
metadata["synopsis"] = synopsis
break
# Extract genres
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I))
genre_links = soup.find_all("a", href=re.compile(r"genre|tag|type", re.I))
if genre_links:
metadata['genres'] = [link.get_text(strip=True) for link in genre_links[:5]]
metadata["genres"] = [
link.get_text(strip=True) for link in genre_links[:5]
]
# Extract rating
rating_selectors = [
'span.rating',
'div.rating',
'span.score',
"span.rating",
"div.rating",
"span.score",
'div[class*="rating"]',
'div[class*="score"]'
'div[class*="score"]',
]
for selector in rating_selectors:
rating_elem = soup.select_one(selector)
if rating_elem:
rating_text = rating_elem.get_text(strip=True)
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
if rating_match:
metadata['rating'] = f"{rating_match.group(1)}/10"
metadata["rating"] = f"{rating_match.group(1)}/10"
break
# Extract release year
page_text = soup.get_text()
year_matches = re.findall(r'\b(19\d{2}|20\d{2})\b', page_text)
year_matches = re.findall(r"\b(19\d{2}|20\d{2})\b", page_text)
if year_matches:
import datetime
current_year = datetime.datetime.now().year + 2
valid_years = [int(y) for y in year_matches if 1950 <= int(y) <= current_year]
valid_years = [
int(y) for y in year_matches if 1950 <= int(y) <= current_year
]
if valid_years:
from collections import Counter
metadata['release_year'] = Counter(valid_years).most_common(1)[0][0]
metadata["release_year"] = Counter(valid_years).most_common(1)[0][0]
# Extract poster image
poster_elem = soup.select_one('img.poster, img.cover, .anime-poster img')
poster_elem = soup.select_one("img.poster, img.cover, .anime-poster img")
if poster_elem:
metadata['poster_image'] = poster_elem.get('src') or poster_elem.get('data-src')
metadata["poster_image"] = poster_elem.get("src") or poster_elem.get(
"data-src"
)
# Extract poster from og:image
og_image = soup.find('meta', property='og:image')
if og_image and not metadata['poster_image']:
metadata['poster_image'] = og_image.get('content')
og_image = soup.find("meta", property="og:image")
if og_image and not metadata["poster_image"]:
metadata["poster_image"] = og_image.get("content")
# Extract total episodes
episodes_count = len(await self.get_episodes(anime_url))
if episodes_count > 0:
metadata['total_episodes'] = episodes_count
metadata["total_episodes"] = episodes_count
print(f"[VOSTFREE] Extracted metadata: {metadata}")
return metadata
@@ -208,7 +220,9 @@ class VostfreeDownloader(BaseAnimeSite):
print(f"[VOSTFREE] Error extracting metadata: {e}")
return {}
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
async def search_anime(
self, query: str, lang: str = "vostfr", include_metadata: bool = False
) -> list[dict]:
"""
Search for anime on vostfree
@@ -219,6 +233,7 @@ class VostfreeDownloader(BaseAnimeSite):
"""
try:
import time
start = time.time()
print(f"[VOSTFREE] Searching for '{query}' ({lang})...")
@@ -233,15 +248,15 @@ class VostfreeDownloader(BaseAnimeSite):
if response.status_code == 200:
print(f"[VOSTFREE] Found anime at {str(response.url)}")
result = {
'title': query,
'url': str(response.url),
'type': 'direct',
'metadata': None
"title": query,
"url": str(response.url),
"type": "direct",
"metadata": None,
}
if include_metadata:
metadata = await self.get_anime_metadata(str(response.url))
result['metadata'] = metadata
result["metadata"] = metadata
return [result]
+126 -137
View File
@@ -1,4 +1,5 @@
"""FS7 (French Stream) series site downloader"""
import logging
import re
from typing import List, Dict, Any, Optional
@@ -19,29 +20,46 @@ class FS7Downloader(BaseSeriesSite):
def __init__(self):
super().__init__()
self.base_url = "https://fs7.lol"
self.search_url = f"{self.base_url}/"
# Update client headers to mimic browser
self.client.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1'
})
self.id = "fs7"
self.provider_id = "fs7"
self.default_domain = "fs7.lol"
self.test_tlds = ["lol", "com", "net", "org", "tv", "ws", "cc", "co"]
self.base_url = f"https://{self.default_domain}"
self._domain_checked = False
self.client.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
)
async def _ensure_base_url(self):
"""Ensure base_url is set to the current active domain"""
if self._domain_checked:
return
self._domain_checked = True
try:
from app.utils import DomainManager
active_domain = await DomainManager.get_active_domain(
self.provider_id, self.default_domain, self.test_tlds, test_path="/"
)
self.base_url = f"https://{active_domain}"
logger.info(f"Using active domain for FS7: {self.base_url}")
except Exception as e:
logger.warning(f"Domain check failed for FS7, using default: {e}")
def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL"""
return "fs7.lol" in url.lower() or "french-stream" in url.lower()
async def search_anime(
self,
query: str,
lang: str = "vf"
) -> List[Dict[str, str]]:
async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
"""
Search for series on FS7.
Search for series on FS7 using DLE AJAX search endpoint.
Args:
query: Search query
@@ -51,91 +69,61 @@ class FS7Downloader(BaseSeriesSite):
List of series with title, url, cover_image
"""
try:
await self._ensure_base_url()
logger.info(f"Searching FS7 for: {query}")
# FS7 uses GET request with query parameters for search
response = await self.client.get(
self.search_url,
params={
"do": "search",
"subaction": "search",
"story": query
}
ajax_url = f"{self.base_url}/engine/ajax/search.php"
response = await self.client.post(
ajax_url,
data={"query": query, "page": "1"},
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Requested-With": "XMLHttpRequest",
"Referer": f"{self.base_url}/",
},
)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
results = []
# Look for series items
# FS7 usually structure: <div class="movie-item">...<a href="..."><img src="..."></a>...</div>
# Or directly <a> tags with images
items = soup.find_all('div', class_='movie-item')
if not items:
# Fallback to the previous method if layout is different
items = soup.find_all('a', href=re.compile(r'/s-tv/\d+-.+\.html'))
for item in items[:24]: # Limit to 24 results
# Find the link and image within the item or the item itself
if item.name == 'a':
link_elem = item
else:
link_elem = item.find('a', href=re.compile(r'/s-tv/|/films/'))
if not link_elem:
for item in soup.find_all("div", class_="search-item")[:24]:
onclick = item.get("onclick", "")
url_match = re.search(r"location\.href=['\"]([^'\"]+)['\"]", onclick)
if not url_match:
continue
url = link_elem.get('href', '')
if not url.startswith('http'):
url = url_match.group(1)
if not url.startswith("http"):
url = urljoin(self.base_url, url)
# Extract title
img_elem = item.find('img')
title = ""
if img_elem and img_elem.get('alt'):
title = img_elem.get('alt').strip()
elif link_elem.get('title'):
title = link_elem.get('title').strip()
else:
title = item.get_text(strip=True)
title_elem = item.find("div", class_="search-title")
title = title_elem.get_text(strip=True) if title_elem else ""
title = re.sub(r"\s+", " ", title).strip()
# Extract cover image
img_elem = item.find('img')
cover_image = ""
if img_elem:
# Check for common lazy loading attributes used by various themes
poster_elem = item.find("div", class_="search-poster")
if poster_elem:
img = poster_elem.find("img")
if img:
cover_image = (
img_elem.get('data-src') or
img_elem.get('data-original') or
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 not any(r['url'] == url for r in results):
results.append({
'title': title,
'url': url,
'cover_image': cover_image
})
results.append(
{
"title": title,
"url": url,
"cover_image": cover_image,
"provider_id": self.provider_id,
}
)
logger.info(f"Found {len(results)} series on FS7")
logger.info(f"Found {len(results)} results on FS7 for '{query}'")
return results
except Exception as e:
@@ -143,9 +131,7 @@ class FS7Downloader(BaseSeriesSite):
return []
async def get_episodes(
self,
anime_url: str,
lang: str = "vf"
self, anime_url: str, lang: str = "vf"
) -> List[Dict[str, str]]:
"""
Get episode list for a series.
@@ -164,31 +150,33 @@ class FS7Downloader(BaseSeriesSite):
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
episodes = []
# Get series title for episode naming
title_elem = soup.find('h1')
title_elem = soup.find("h1")
series_title = title_elem.get_text(strip=True) if title_elem else "Series"
# Clean up title: remove "affiche" suffix
series_title = re.sub(r'\s+affiche$', '', series_title, flags=re.IGNORECASE).strip()
series_title = re.sub(
r"\s+affiche$", "", series_title, flags=re.IGNORECASE
).strip()
# FS7 stores episode data in JavaScript div elements
# Format: <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:
ep_num = div.get('data-ep', '').strip()
ep_num = div.get("data-ep", "").strip()
# Try different video players in order of preference
video_url = None
host_name = None
for player in ['data-vidzy', 'data-uqload', 'data-voe', 'data-netu']:
player_url = div.get(player, '').strip()
for player in ["data-vidzy", "data-uqload", "data-voe", "data-netu"]:
player_url = div.get(player, "").strip()
if player_url:
video_url = player_url
# Extract host name from attribute name
host_name = player.replace('data-', '').title()
host_name = player.replace("data-", "").title()
logger.debug(f"Found episode {ep_num} on {host_name}")
break
@@ -199,15 +187,19 @@ class FS7Downloader(BaseSeriesSite):
# Use pipe-separated format: video_url|anime_url|episode_title
combined_url = f"{video_url}|{anime_url}|{episode_title}"
episodes.append({
'episode': ep_num,
'url': combined_url,
'title': episode_title,
'host': host_name or 'Unknown'
})
episodes.append(
{
"episode": ep_num,
"url": combined_url,
"title": episode_title,
"host": host_name or "Unknown",
}
)
# Sort by episode number
episodes.sort(key=lambda x: int(x['episode']) if x['episode'].isdigit() else 0)
episodes.sort(
key=lambda x: int(x["episode"]) if x["episode"].isdigit() else 0
)
logger.info(f"Found {len(episodes)} episodes")
return episodes
@@ -216,10 +208,7 @@ class FS7Downloader(BaseSeriesSite):
logger.error(f"Error getting episodes from FS7: {e}")
return []
async def get_anime_metadata(
self,
anime_url: str
) -> Dict[str, Any]:
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
"""
Get metadata for a series.
@@ -236,62 +225,62 @@ class FS7Downloader(BaseSeriesSite):
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
# Extract title
title = soup.find('h1')
title = soup.find("h1")
title = title.get_text(strip=True) if title else "Unknown"
# Clean up title: remove "affiche" suffix
title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip()
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
# Extract description/synopsis
description_elem = soup.find('div', class_='full-text')
description = description_elem.get_text(strip=True) if description_elem else ""
description_elem = soup.find("div", class_="full-text")
description = (
description_elem.get_text(strip=True) if description_elem else ""
)
# Extract cover image
img = soup.find('img', class_='poster')
poster_image = img.get('src', '') if img else ''
img = soup.find("img", class_="poster")
poster_image = img.get("src", "") if img else ""
# Try to get poster from meta tag if not found
if not poster_image:
meta_img = soup.find('meta', property='og:image')
poster_image = meta_img.get('content', '') if meta_img else ''
meta_img = soup.find("meta", property="og:image")
poster_image = meta_img.get("content", "") if meta_img else ""
# Extract year
year_match = re.search(r'\b(19|20)\d{2}\b', description)
year_match = re.search(r"\b(19|20)\d{2}\b", description)
release_year = int(year_match.group()) if year_match else None
return {
'title': title,
'synopsis': description,
'poster_image': poster_image,
'release_year': release_year,
'genres': [],
'rating': None,
'studio': None,
'total_episodes': None,
'status': None
"title": title,
"synopsis": description,
"poster_image": poster_image,
"release_year": release_year,
"genres": [],
"rating": None,
"studio": None,
"total_episodes": None,
"status": None,
}
except Exception as e:
logger.error(f"Error getting metadata from FS7: {e}")
return {
'title': "Unknown",
'synopsis': "",
'poster_image': '',
'genres': [],
'rating': None,
'release_year': None,
'studio': None,
'total_episodes': None,
'status': None
"title": "Unknown",
"synopsis": "",
"poster_image": "",
"genres": [],
"rating": None,
"release_year": None,
"studio": None,
"total_episodes": None,
"status": None,
}
async def get_download_link(
self,
url: str,
target_filename: Optional[str] = None
self, url: str, target_filename: Optional[str] = None
) -> tuple[str, str]:
"""
Extract download link from video player URL.
@@ -1,4 +1,5 @@
"""Zone-Telechargement series site downloader"""
import logging
import re
from typing import List, Dict, Any, Optional, Tuple
@@ -18,94 +19,106 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
def __init__(self):
super().__init__()
self.id = "zonetelechargement"
self.provider_id = "zonetelechargement"
self.default_domain = "zone-telechargement.cam"
self.test_tlds = ["cam", "net", "org", "blue", "lol", "work"]
self.base_url = None # Will be set dynamically
self.default_domain = "zone-telechargement.golf"
self.test_tlds = ["golf", "cam", "net", "org", "blue", "lol", "work", "ws"]
self.base_url = f"https://{self.default_domain}"
self._domain_checked = False
self.client.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7',
})
self.client.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
}
)
async def _ensure_base_url(self):
"""Ensure base_url is set to the current active domain"""
if not self.base_url:
if self._domain_checked:
return
self._domain_checked = True
try:
active_domain = await DomainManager.get_active_domain(
self.provider_id,
self.default_domain,
self.test_tlds,
test_path="/"
self.provider_id, self.default_domain, self.test_tlds, test_path="/"
)
self.base_url = f"https://{active_domain}"
logger.info(f"Using active domain for Zone-Telechargement: {self.base_url}")
except Exception as e:
logger.warning(
f"Domain check failed for Zone-Telechargement, using default: {e}"
)
def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL"""
return "zone-telechargement" in url.lower() or "zt-za" in url.lower()
async def search_anime(
self,
query: str,
lang: str = "vf"
) -> List[Dict[str, str]]:
"""Search for series on Zone-Telechargement"""
async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
"""Search for series on Zone-Telechargement.
ZT uses server-side rendered search: GET /?p=series&search=QUERY.
Results are in div.cover_global containers with nested cover_infos_title links.
"""
try:
await self._ensure_base_url()
logger.info(f"Searching Zone-Telechargement for: {query}")
# ZT uses POST or GET for search depending on the version
# Most modern versions use: /index.php?do=search
search_url = f"{self.base_url}/index.php?do=search"
search_url = f"{self.base_url}/"
params = {"p": "series", "search": query}
# 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 = await self.client.get(search_url, params=params)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
results = []
# Look for items
items = soup.find_all('div', class_='shm-item') or soup.find_all('div', class_='movie-item')
for cover_div in soup.find_all("div", class_="cover_global")[:24]:
link_in_cover = cover_div.find("a", class_="mainimg")
if not link_in_cover:
link_in_cover = cover_div.find("a")
for item in items[:24]:
link_elem = item.find('a', class_='shm-title') or item.find('a')
if not link_elem:
if not link_in_cover:
continue
url = link_elem.get('href', '')
if not url.startswith('http'):
url = link_in_cover.get("href", "")
if not url.startswith("http"):
url = urljoin(self.base_url, url)
title = link_elem.get_text(strip=True)
img_elem = item.find('img')
img = cover_div.find("img")
cover_image = ""
if img_elem:
cover_image = img_elem.get('data-src') or img_elem.get('src') or ""
if cover_image and not cover_image.startswith('http'):
if img:
cover_image = img.get("data-src") or img.get("src") or ""
if cover_image and not cover_image.startswith("http"):
cover_image = urljoin(self.base_url, cover_image)
if title and len(title) > 2:
results.append({
'title': title,
'url': url,
'cover_image': cover_image,
'provider_id': self.provider_id
})
title = ""
info_div = cover_div.find("div", class_="cover_infos_title")
if info_div:
title_link = info_div.find("a")
if title_link:
title = title_link.get_text(strip=True)
else:
title = info_div.get_text(strip=True)
else:
title = link_in_cover.get("title", "")
if not title:
title = link_in_cover.get_text(strip=True)
if title and len(title) > 2:
results.append(
{
"title": title,
"url": url,
"cover_image": cover_image,
"provider_id": self.provider_id,
}
)
logger.info(
f"Zone-Telechargement found {len(results)} results for '{query}'"
)
return results
except Exception as e:
@@ -113,39 +126,35 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
return []
async def get_episodes(
self,
anime_url: str,
lang: str = "vf"
self, anime_url: str, lang: str = "vf"
) -> List[Dict[str, str]]:
"""Extract episodes from a series page"""
try:
await self._ensure_base_url()
html = await self._fetch_page(anime_url)
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
episodes = []
# ZT typically lists episodes in a table or list of links
# Links often look like: /telecharger-series/.../saison-X-episode-Y.html
links = soup.find_all('a', href=re.compile(r'episode-\d+'))
links = soup.find_all("a", href=re.compile(r"episode-\d+"))
for i, link in enumerate(links):
href = link.get('href', '')
if not href.startswith('http'):
href = link.get("href", "")
if not href.startswith("http"):
href = urljoin(self.base_url, href)
title = link.get_text(strip=True)
ep_match = re.search(r'episode\s*(\d+)', title.lower())
ep_match = re.search(r"episode\s*(\d+)", title.lower())
ep_number = int(ep_match.group(1)) if ep_match else i + 1
episodes.append({
'episode_number': ep_number,
'url': href,
'title': title
})
episodes.append(
{"episode_number": ep_number, "url": href, "title": title}
)
# Sort by episode number
episodes.sort(key=lambda x: x['episode_number'])
episodes.sort(key=lambda x: x["episode_number"])
return episodes
except Exception as e:
@@ -157,29 +166,37 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
try:
await self._ensure_base_url()
html = await self._fetch_page(anime_url)
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
metadata = {
'title': "",
'synopsis': "",
'genres': [],
'poster_image': "",
'status': "Unknown"
"title": "",
"synopsis": "",
"genres": [],
"poster_image": "",
"status": "Unknown",
}
title_elem = soup.find('h1')
title_elem = soup.find("h1")
if title_elem:
metadata['title'] = title_elem.get_text(strip=True)
metadata["title"] = title_elem.get_text(strip=True)
# Synopsis
syn_elem = soup.find('div', class_='shm-description') or soup.find('div', class_='movie-desc')
syn_elem = soup.find("div", class_="shm-description") or soup.find(
"div", class_="movie-desc"
)
if syn_elem:
metadata['synopsis'] = syn_elem.get_text(strip=True)
metadata["synopsis"] = syn_elem.get_text(strip=True)
# Poster
img_elem = soup.find('div', class_='shm-img').find('img') if soup.find('div', class_='shm-img') else None
img_elem = (
soup.find("div", class_="shm-img").find("img")
if soup.find("div", class_="shm-img")
else None
)
if img_elem:
metadata['poster_image'] = urljoin(self.base_url, img_elem.get('src', ''))
metadata["poster_image"] = urljoin(
self.base_url, img_elem.get("src", "")
)
return metadata
@@ -192,15 +209,21 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
try:
await self._ensure_base_url()
html = await self._fetch_page(url)
soup = BeautifulSoup(html, 'lxml')
soup = BeautifulSoup(html, "lxml")
# Look for video player links (Uptobox, 1fichier, etc.)
# ZT often has multiple hosts
links = soup.find_all('a', href=re.compile(r'uptobox|1fichier|doodstream|vidmoly'))
links = soup.find_all(
"a", href=re.compile(r"uptobox|1fichier|doodstream|vidmoly")
)
if links:
player_url = links[0].get('href', '')
title = soup.find('h1').get_text(strip=True) if soup.find('h1') else "Episode"
player_url = links[0].get("href", "")
title = (
soup.find("h1").get_text(strip=True)
if soup.find("h1")
else "Episode"
)
return player_url, title
return "", ""
+28 -17
View File
@@ -1,16 +1,26 @@
# Video Players (app/downloaders/video_players)
# Video Players (app/downloaders/video_players/)
## OVERVIEW
File hosting extractors that extract direct download links from video player pages (Doodstream, Sibnet, VidMoly, etc.).
File hosting extractors that extract direct download links from video player pages (Doodstream, Sibnet, VidMoly, Uptobox, etc.).
## WHERE TO LOOK
| Need | File |
|------|------|
| Base class | `base.py` - `BaseVideoPlayer` abstract class |
| Add new player | Create new `.py` file, inherit `BaseVideoPlayer`, add to `__init__.py` |
| URL detection logic | Each player's `can_handle()` method |
| Extract download link | Each player's `get_download_link()` method |
| File | Purpose |
|------|---------|
| `base.py` | `BaseVideoPlayer` abstract class |
| `unfichier.py` | 1fichier.com |
| `doodstream.py` | Doodstream |
| `vidmoly.py` | VidMoly (requires Playwright for extraction) |
| `uptobox.py` | Uptobox |
| `sendvid.py` | SendVid |
| `sibnet.py` | Sibnet |
| `rapidfile.py` | Rapidfile |
| `uqload.py` | Uqload |
| `lpayer.py` | Lplayer |
| `vidzy.py` | Vidzy |
| `luluv.py` | LuLuvid |
| `smoothpre.py` | Smoothpre |
| `oneupload.py` | OneUpload |
## CONVENTIONS
@@ -22,16 +32,17 @@ def can_handle(self, url: str) -> bool: ...
async def get_download_link(self, url: str, target_filename: str = None) -> tuple[str, str]: ...
```
**File operation**: Always use `sanitize_filename()` on extracted filenames.
**HTTP client**: Use `self.client` (AsyncClient from base class). Always close via `await self.close()` when done.
**Return format**: `(download_url, filename)` tuple.
**HTTP client**: Use `self.client` (AsyncClient from base class). Always close via `await self.close()`.
**File operation**: Always `sanitize_filename()` on extracted filenames.
## ANTI-PATTERNS
- Do NOT hardcode User-Agent in each player (use base class headers)
- Do NOT forget to call `await self.close()` after extraction
- Do NOT return None for missing URLs, raise an exception
- Do NOT use sync `requests`, use async `httpx`
- Do NOT skip the `target_filename` parameter, even if unused
- Do NOT hardcode User-Agent per player use base class headers
- Do NOT forget `await self.close()` — resource leak
- Do NOT return None for missing URLs raise an exception
- Do NOT use sync `requests` use async `httpx`
- Do NOT skip `target_filename` parameter — required for anime/series site compatibility
- 8 empty `except:` blocks across players — known tech debt
+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-sama": {
"name": "Anime-Sama",
"domains": ["anime-sama.to", "www.anime-sama.to", "anime-sama.tv", "www.anime-sama.tv", "anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"],
"domains": [
"anime-sama.to",
"www.anime-sama.to",
"anime-sama.tv",
"www.anime-sama.tv",
"anime-sama.si",
"www.anime-sama.si",
"anime-sama.org",
"anime-sama.store",
"anime-sama.eu",
],
"url_pattern": "https://anime-sama.to/catalogue/{anime}/saison{season}/{lang}/",
"icon": "🎬",
"color": "#00d9ff"
"color": "#00d9ff",
},
"anime-ultime": {
"name": "Anime-Ultime",
"domains": ["anime-ultime.net", "anime-ultime.com", "www.anime-ultime.net"],
"url_pattern": "https://www.anime-ultime.net/info-{id}-{slug}",
"icon": "▶️",
"color": "#00ff88"
"color": "#00ff88",
},
"neko-sama": {
"name": "Neko-Sama",
"domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"],
"url_pattern": "https://neko-sama.fr/anime/{slug}",
"icon": "🐱",
"color": "#ff6b6b"
"color": "#ff6b6b",
},
"vostfree": {
"name": "Vostfree",
"domains": ["vostfree.tv", "www.vostfree.tv"],
"url_pattern": "https://vostfree.tv/anime/{slug}",
"icon": "📺",
"color": "#ffd93d"
"color": "#ffd93d",
},
"french-manga": {
"name": "French-Manga",
"domains": ["french-manga.net", "w16.french-manga.net", "w15.french-manga.net", "www.french-manga.net"],
"domains": [
"french-manga.net",
"w16.french-manga.net",
"w15.french-manga.net",
"www.french-manga.net",
],
"url_pattern": "https://w16.french-manga.net/{slug}.html",
"icon": "🇫🇷",
"color": "#ff7675"
}
"color": "#ff7675",
},
}
SERIES_PROVIDERS = {
"fs7": {
"name": "French Stream",
"domains": ["fs7.lol", "www.fs7.lol", "french-stream.tv", "www.french-stream.tv"],
"domains": [
"fs7.lol",
"www.fs7.lol",
"french-stream.tv",
"www.french-stream.tv",
"fs7.com",
"fs7.net",
"fs7.org",
"fs7.cc",
"fs7.co",
"french-stream.com",
"french-stream.net",
],
"url_pattern": "https://fs7.lol/s-tv/{slug}.html",
"icon": "🎬",
"color": "#ff6b9d"
"color": "#ff6b9d",
},
"zonetelechargement": {
"name": "Zone-Telechargement",
"domains": ["zone-telechargement.cam", "zone-telechargement.net", "zone-telechargement.org", "zone-telechargement.blue", "zone-telechargement.lol", "zone-telechargement.work"],
"url_pattern": "https://zone-telechargement.cam/index.php?do=search",
"domains": [
"zone-telechargement.golf",
"zone-telechargement.cam",
"zone-telechargement.net",
"zone-telechargement.org",
"zone-telechargement.blue",
"zone-telechargement.lol",
"zone-telechargement.work",
"zone-telechargement.ws",
"www.zone-telechargement.golf",
"www.zone-telechargement.cam",
],
"url_pattern": "https://zone-telechargement.golf/index.php?do=search",
"icon": "⬇️",
"color": "#00d9ff"
}
"color": "#00d9ff",
},
}
FILE_HOSTS = {
@@ -60,98 +98,112 @@ FILE_HOSTS = {
"name": "1fichier",
"domains": ["1fichier.com", "1fichier.fr"],
"icon": "📁",
"color": "#4ecdc4"
"color": "#4ecdc4",
},
"uptobox": {
"name": "Uptobox",
"domains": ["uptobox.com", "uptobox.fr"],
"icon": "📦",
"color": "#45b7d1"
"color": "#45b7d1",
},
"doodstream": {
"name": "Doodstream",
"domains": ["doodstream.com", "dood.stream", "dood.to", "dood.lol", "dood.cx", "dood.so", "dood.watch", "dood.sh"],
"domains": [
"doodstream.com",
"dood.stream",
"dood.to",
"dood.lol",
"dood.cx",
"dood.so",
"dood.watch",
"dood.sh",
],
"icon": "🎥",
"color": "#f7b731"
"color": "#f7b731",
},
"rapidfile": {
"name": "Rapidfile",
"domains": ["rapidfile.net", "rapidfile.com"],
"icon": "",
"color": "#ff6b6b"
"color": "#ff6b6b",
},
"vidmoly": {
"name": "VidMoly",
"domains": ["vidmoly.to", "vidmoly.org", "vidmoly.biz"],
"icon": "🎬",
"color": "#a29bfe"
"color": "#a29bfe",
},
"sendvid": {
"name": "SendVid",
"domains": ["sendvid.com", "sendvid.io"],
"icon": "📤",
"color": "#fd79a8"
"color": "#fd79a8",
},
"sibnet": {
"name": "Sibnet",
"domains": ["sibnet.ru", "video.sibnet.ru"],
"icon": "🎞️",
"color": "#00cec9"
"color": "#00cec9",
},
"lpayer": {
"name": "Lplayer",
"domains": ["lpayer.embed4me.com", "lpayer.com", "lplayer.fr"],
"icon": "▶️",
"color": "#e17055"
"color": "#e17055",
},
"vidzy": {
"name": "Vidzy",
"domains": ["vidzy.com", "vidzy.net", "www.vidzy.com"],
"icon": "🎞️",
"color": "#74b9ff"
"color": "#74b9ff",
},
"luluv": {
"name": "LuLuvid",
"domains": ["luluv.com", "luluvid.com", "www.luluv.com", "www.luluvid.com"],
"icon": "🎬",
"color": "#a29bfe"
"color": "#a29bfe",
},
"uqload": {
"name": "Uqload",
"domains": ["uqload.bz", "uqload.com", "www.uqload.bz", "www.uqload.com"],
"icon": "📺",
"color": "#fd79a8"
"color": "#fd79a8",
},
"smoothpre": {
"name": "Smoothpre",
"domains": ["smoothpre.com", "www.smoothpre.com"],
"icon": "🎬",
"color": "#a29bfe"
}
"color": "#a29bfe",
},
}
def get_all_providers():
"""Get all supported providers (anime + series + file hosts)"""
return {**ANIME_PROVIDERS, **SERIES_PROVIDERS, **FILE_HOSTS}
def get_anime_providers():
"""Get all anime streaming providers"""
return ANIME_PROVIDERS
def get_series_providers():
"""Get all series streaming providers"""
return SERIES_PROVIDERS
def get_file_hosts():
"""Get all file hosting providers"""
return FILE_HOSTS
def detect_provider_from_url(url: str) -> str | None:
"""Detect which provider can handle the given URL"""
url_lower = url.lower()
for provider_id, provider in get_all_providers().items():
for domain in provider['domains']:
for domain in provider["domains"]:
if domain in url_lower:
return provider_id
+88 -12
View File
@@ -1,4 +1,5 @@
"""Manages scraper providers and their health status"""
import os
import logging
import asyncio
@@ -7,6 +8,18 @@ from pathlib import Path
from datetime import datetime
from app.downloaders.generic_scraper import GenericScraper
from app.downloaders.anime_sites import (
AnimeSamaDownloader,
NekoSamaDownloader,
AnimeUltimeDownloader,
VostfreeDownloader,
FrenchMangaDownloader,
)
from app.downloaders.series_sites import (
FS7Downloader,
ZoneTelechargementDownloader,
)
from app.providers import ANIME_PROVIDERS, SERIES_PROVIDERS
logger = logging.getLogger(__name__)
@@ -16,11 +29,13 @@ class ProvidersManager:
def __init__(self, config_dir: str = "app/downloaders/providers_config"):
self.config_dir = Path(config_dir)
self.providers: Dict[str, GenericScraper] = {}
self.providers: Dict[str, object] = {}
self.provider_info: Dict[str, Dict] = {}
self.health_status: Dict[str, Dict] = {}
self._load_providers()
self._load_yaml_providers()
self._load_hardcoded_providers()
def _load_providers(self):
def _load_yaml_providers(self):
"""Load all providers from YAML configs"""
if not self.config_dir.exists():
logger.warning(f"Providers config directory not found: {self.config_dir}")
@@ -33,12 +48,38 @@ class ProvidersManager:
self.health_status[scraper.id] = {
"status": "unknown",
"last_check": None,
"error": None
"error": None,
}
logger.info(f"Loaded provider: {scraper.name} ({scraper.id})")
logger.info(f"Loaded YAML provider: {scraper.name} ({scraper.id})")
except Exception as e:
logger.error(f"Failed to load provider from {config_file}: {e}")
def _load_hardcoded_providers(self):
"""Load hardcoded Python providers"""
provider_classes = [
("anime-sama", AnimeSamaDownloader, ANIME_PROVIDERS),
("neko-sama", NekoSamaDownloader, ANIME_PROVIDERS),
("anime-ultime", AnimeUltimeDownloader, ANIME_PROVIDERS),
("vostfree", VostfreeDownloader, ANIME_PROVIDERS),
("french-manga", FrenchMangaDownloader, ANIME_PROVIDERS),
("fs7", FS7Downloader, SERIES_PROVIDERS),
("zonetelechargement", ZoneTelechargementDownloader, SERIES_PROVIDERS),
]
for provider_id, provider_class, provider_dict in provider_classes:
if provider_id in provider_dict:
try:
self.providers[provider_id] = provider_class()
self.provider_info[provider_id] = provider_dict[provider_id]
self.health_status[provider_id] = {
"status": "unknown",
"last_check": None,
"error": None,
}
logger.info(f"Loaded hardcoded provider: {provider_id}")
except Exception as e:
logger.error(f"Failed to load provider {provider_id}: {e}")
async def check_all_health(self):
"""Check health of all registered providers"""
logger.info("Checking health of all providers...")
@@ -49,30 +90,65 @@ class ProvidersManager:
await asyncio.gather(*tasks)
logger.info("Provider health check complete")
async def _check_single_health(self, provider_id: str, scraper: GenericScraper):
async def _check_single_health(self, provider_id: str, scraper):
"""Check health of a single provider and update status"""
try:
is_healthy = await scraper.check_health()
is_healthy = await self._do_health_check(scraper)
self.health_status[provider_id] = {
"status": "up" if is_healthy else "down",
"last_check": datetime.now().isoformat(),
"error": None if is_healthy else "No search results returned"
"error": None if is_healthy else "No search results returned",
}
except Exception as e:
self.health_status[provider_id] = {
"status": "down",
"last_check": datetime.now().isoformat(),
"error": str(e)
"error": str(e),
}
logger.error(f"Health check failed for {provider_id}: {e}")
def get_provider(self, provider_id: str) -> Optional[GenericScraper]:
async def _do_health_check(self, scraper) -> bool:
"""Perform health check on a scraper"""
try:
if hasattr(scraper, "check_health"):
return await scraper.check_health()
elif hasattr(scraper, "client"):
# Test basic connectivity
base_url = getattr(scraper, "base_url", None) or getattr(
scraper, "active_url", None
)
if base_url:
if hasattr(scraper, "_ensure_base_url"):
await scraper._ensure_base_url()
base_url = getattr(scraper, "base_url", base_url)
response = await scraper.client.get(base_url, timeout=15.0)
return 200 <= response.status_code < 400
elif hasattr(scraper, "BASE_DOMAINS") and scraper.BASE_DOMAINS:
# Test first domain from BASE_DOMAINS
test_url = f"https://{scraper.BASE_DOMAINS[0]}"
response = await scraper.client.get(test_url, timeout=15.0)
return 200 <= response.status_code < 400
elif hasattr(scraper, "search_anime"):
results = await scraper.search_anime("One Piece", lang="vostfr")
return len(results) > 0
elif hasattr(scraper, "search"):
results = await scraper.search("One Piece")
return len(results) > 0
return False
except Exception as e:
logger.error(
f"Health check exception for {getattr(scraper, 'provider_id', scraper)}: {e}"
)
return False
def get_provider(self, provider_id: str):
return self.providers.get(provider_id)
def get_active_providers(self) -> List[GenericScraper]:
def get_active_providers(self) -> List:
"""Return only providers that are UP or UNKNOWN"""
return [
self.providers[pid] for pid, status in self.health_status.items()
self.providers[pid]
for pid, status in self.health_status.items()
if status["status"] != "down"
]
+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
+76 -29
View File
@@ -9,7 +9,15 @@ import logging
import asyncio
import hashlib
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
HTTPException,
Query,
Request,
Response,
)
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
@@ -35,10 +43,12 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["anime"])
templates = Jinja2Templates(directory="templates")
# Add custom filters to Jinja2
def hash_filter(s):
return hashlib.md5(s.encode()).hexdigest()[:10]
templates.env.filters["hash"] = hash_filter
@@ -52,6 +62,7 @@ async def get_providers_health():
async def trigger_providers_health_check(background_tasks: BackgroundTasks):
"""Trigger a manual health check of all providers in the background"""
from app.auto_download_scheduler import auto_download_scheduler
background_tasks.add_task(auto_download_scheduler.trigger_health_check_now)
return {"status": "Health check triggered in background"}
@@ -59,6 +70,7 @@ async def trigger_providers_health_check(background_tasks: BackgroundTasks):
def get_download_manager() -> DownloadManager:
"""Get the download manager instance from main app"""
from main import download_manager
return download_manager
@@ -73,7 +85,7 @@ async def search_anime_unified(
include_metadata: bool = False,
html: bool = Query(False),
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
session: Session = Depends(get_session),
):
"""
Search across all anime providers.
@@ -83,7 +95,9 @@ async def search_anime_unified(
start_time = time.time()
# Get user settings for disabled providers
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
statement = select(AppSettingsTable).where(
AppSettingsTable.user_id == current_user.id
)
settings_obj = session.exec(statement).first()
disabled_providers = settings_obj.disabled_providers if settings_obj else []
@@ -96,19 +110,26 @@ async def search_anime_unified(
# Generic YAML providers
active_generic = providers_manager.get_active_providers()
for provider in active_generic:
if provider.id not in disabled_providers:
provider_id = getattr(provider, "id", None)
if provider_id and provider_id not in disabled_providers:
if hasattr(provider, "search"):
search_tasks.append(provider.search(q))
task_metadata.append({"id": provider.id, "type": "generic"})
task_metadata.append({"id": provider_id, "type": "generic"})
elif hasattr(provider, "search_anime"):
search_tasks.append(provider.search_anime(q, lang))
task_metadata.append({"id": provider_id, "type": "legacy"})
# Legacy providers
# Legacy providers (already included in providers_manager, but keep for fallback)
legacy_downloaders = {
"anime-ultime": AnimeUltimeDownloader(),
"neko-sama": NekoSamaDownloader(),
"vostfree": VostfreeDownloader(),
}
for pid, dl in legacy_downloaders.items():
if pid not in disabled_providers:
search_tasks.append(dl.search_anime(q, lang, include_metadata=False))
if pid not in disabled_providers and pid not in {
getattr(p, "id", None) for p in active_generic
}:
search_tasks.append(dl.search_anime(q, lang))
task_metadata.append({"id": pid, "type": "legacy"})
# 2. Run searches in parallel
@@ -147,7 +168,13 @@ async def search_anime_unified(
# Prepare enrichment task for top 5 results per provider
if len(results[pid]) <= 5:
enrichment_tasks.append(enricher.enrich_metadata(item_dict.get("metadata", {}), item_dict.get("title", ""), url))
enrichment_tasks.append(
enricher.enrich_metadata(
item_dict.get("metadata", {}),
item_dict.get("title", ""),
url,
)
)
enrichment_mapping.append((pid, len(results[pid]) - 1))
else:
if "metadata" not in item_dict:
@@ -170,18 +197,16 @@ async def search_anime_unified(
elapsed = time.time() - start_time
total_found = sum(len(r) for r in results.values())
print(f"[SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}")
print(
f"[SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}"
)
# 6. Return HTML for HTMX or JSON for API
if html or request.headers.get("HX-Request"):
print("[SEARCH] Returning HTML response")
return templates.TemplateResponse(
"components/anime_search_results.html",
{
"request": request,
"results": results,
"settings": settings_obj
}
{"request": request, "results": results, "settings": settings_obj},
)
print("[SEARCH] Returning JSON response")
@@ -200,7 +225,7 @@ async def search_series_unified(
lang: str = "vf",
html: bool = Query(False),
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
session: Session = Depends(get_session),
):
"""
Search across all TV series providers (FS7, etc.)
@@ -213,14 +238,16 @@ async def search_series_unified(
start_time = time.time()
# Get user settings for disabled providers
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
statement = select(AppSettingsTable).where(
AppSettingsTable.user_id == current_user.id
)
settings_obj = session.exec(statement).first()
disabled_providers = settings_obj.disabled_providers if settings_obj else []
results = {}
series_downloaders = {
"fs7": FS7Downloader(),
"zonetelechargement": ZoneTelechargementDownloader()
"zonetelechargement": ZoneTelechargementDownloader(),
}
search_tasks = []
provider_ids = []
@@ -236,22 +263,24 @@ async def search_series_unified(
for provider_id, result in zip(provider_ids, search_results):
if isinstance(result, Exception):
print(f"[SERIES SEARCH] {provider_id} error: {str(result)}")
logger.error(f"Series search error for {provider_id}: {result}")
elif result:
results[provider_id] = result
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
else:
print(f"[SERIES SEARCH] {provider_id}: No results returned")
elapsed = time.time() - start_time
total_found = sum(len(r) for r in results.values())
print(f"[SERIES SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}")
print(
f"[SERIES SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}"
)
# Return HTML for HTMX or JSON for API
if html or request.headers.get("HX-Request"):
return templates.TemplateResponse(
"components/series_search_results.html",
{
"request": request,
"results": results,
"settings": settings_obj
}
{"request": request, "results": results, "settings": settings_obj},
)
return {"query": q, "lang": lang, "results": results}
@@ -266,7 +295,10 @@ async def get_anime_metadata(url: str):
metadata = await downloader.get_anime_metadata(url)
return {"url": url, "metadata": metadata}
else:
raise HTTPException(status_code=400, detail=f"Downloader for {url} does not support metadata extraction")
raise HTTPException(
status_code=400,
detail=f"Downloader for {url} does not support metadata extraction",
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -301,8 +333,8 @@ async def get_anime_episodes(
"episodes": episodes,
"anime_url": url,
"anime_title": anime_title,
"lang": lang
}
"lang": lang,
},
)
return {"url": url, "lang": lang, "episodes": episodes}
@@ -331,7 +363,14 @@ async def download_anime_episode(
background_tasks.add_task(download_manager.start_download, task.id)
# Add toast notification for HTMX
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"Téléchargement lancé : {task.filename}", "type": "success"}})
response.headers["HX-Trigger"] = json.dumps(
{
"show-toast": {
"message": f"Téléchargement lancé : {task.filename}",
"type": "success",
}
}
)
return {"task_id": task.id, "task": task}
@@ -381,6 +420,7 @@ async def search_anime_mal_details(
):
"""Search for anime on MyAnimeList and get full details"""
from app.recommendations import AnimeReleasesFetcher
fetcher = AnimeReleasesFetcher()
try:
search_results = await fetcher.search_anime(q, limit=limit)
@@ -401,6 +441,7 @@ async def search_anime_mal_details(
async def translate_text(request: Request):
"""Translate text from English to French using Google Translate"""
import httpx
try:
body = await request.json()
text = body.get("text", "")
@@ -408,7 +449,13 @@ async def translate_text(request: Request):
raise HTTPException(status_code=400, detail="Text is required")
async with httpx.AsyncClient(timeout=30.0) as client:
url = "https://translate.googleapis.com/translate_a/single"
params = {"client": "gtx", "sl": "en", "tl": "fr", "dt": "t", "q": text[:5000]}
params = {
"client": "gtx",
"sl": "en",
"tl": "fr",
"dt": "t",
"q": text[:5000],
}
response = await client.get(url, params=params)
if response.status_code == 200:
data = response.json()
+51 -19
View File
@@ -10,6 +10,7 @@ Endpoints:
"""
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -39,15 +40,48 @@ async def get_current_user_from_token(
headers={"WWW-Authenticate": "Bearer"},
)
user_dict = user_manager.get_user(username)
if user_dict is None:
user = user_manager.get_user(username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return User(**user_dict)
return User(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
created_at=user.created_at,
last_login=user.last_login,
)
async def get_optional_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
HTTPBearer(auto_error=False)
),
) -> Optional[User]:
if credentials is None:
return None
token = credentials.credentials
username = verify_token(token)
if username is None:
return None
user = user_manager.get_user(username)
if user is None:
return None
return User(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
created_at=user.created_at,
last_login=user.last_login,
)
@router.post("/register")
@@ -69,15 +103,13 @@ async def register(user_data: UserCreate):
)
user_response = User(
id=user["id"],
username=user["username"],
email=user.get("email"),
full_name=user.get("full_name"),
is_active=user["is_active"],
created_at=datetime.fromisoformat(user["created_at"]),
last_login=datetime.fromisoformat(user["last_login"])
if user.get("last_login")
else None,
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
created_at=user.created_at,
last_login=user.last_login,
)
return {
@@ -111,23 +143,23 @@ async def login(form_data: UserLogin):
headers={"WWW-Authenticate": "Bearer"},
)
if not user.get("is_active", True):
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
)
access_token = create_access_token(
data={"sub": user["username"]}, expires_delta=timedelta(days=7)
data={"sub": user.username}, expires_delta=timedelta(days=7)
)
return {
"access_token": access_token,
"token_type": "bearer",
"user": {
"id": user["id"],
"username": user["username"],
"email": user.get("email"),
"full_name": user.get("full_name"),
"id": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
},
}
@@ -185,7 +217,7 @@ async def refresh_token(refresh_request: dict):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
)
if not user.get("is_active", True):
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
)
+44 -25
View File
@@ -1,6 +1,7 @@
"""Application settings routes for Ohm Stream Downloader API"""
import json
from typing import List, Dict, Any
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
@@ -8,7 +9,7 @@ from sqlmodel import Session, select
from app.database import get_session
from app.models.auth import User, UserTable
from app.models.settings import AppSettings, AppSettingsTable, AppSettingsUpdate
from app.routers.router_auth import get_current_user_from_token
from app.routers.router_auth import get_current_user_from_token, get_optional_user
from app.providers import get_anime_providers, get_series_providers
from app.providers_manager import providers_manager
@@ -19,10 +20,12 @@ templates = Jinja2Templates(directory="templates")
@router.get("", response_model=AppSettings)
async def get_settings(
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
session: Session = Depends(get_session),
):
"""Get current application settings for the user"""
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
statement = select(AppSettingsTable).where(
AppSettingsTable.user_id == current_user.id
)
settings_obj = session.exec(statement).first()
if not settings_obj:
@@ -35,7 +38,7 @@ async def get_settings(
return AppSettings(
default_lang=settings_obj.default_lang,
theme=settings_obj.theme,
disabled_providers=settings_obj.disabled_providers
disabled_providers=settings_obj.disabled_providers,
)
@@ -44,10 +47,12 @@ async def update_settings(
update_data: AppSettingsUpdate,
response: Response,
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
session: Session = Depends(get_session),
):
"""Update application settings for the user"""
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
statement = select(AppSettingsTable).where(
AppSettingsTable.user_id == current_user.id
)
settings_obj = session.exec(statement).first()
if not settings_obj:
@@ -65,7 +70,9 @@ async def update_settings(
session.commit()
session.refresh(settings_obj)
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Paramètres enregistrés", "type": "success"}})
response.headers["HX-Trigger"] = json.dumps(
{"show-toast": {"message": "Paramètres enregistrés", "type": "success"}}
)
return settings_obj
@@ -73,11 +80,13 @@ async def update_settings(
@router.get("/providers/availability")
async def get_providers_availability(
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
session: Session = Depends(get_session),
):
"""Get list of providers with their availability and enabled status"""
# Get user settings
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
statement = select(AppSettingsTable).where(
AppSettingsTable.user_id == current_user.id
)
settings_obj = session.exec(statement).first()
disabled_providers = settings_obj.disabled_providers if settings_obj else []
@@ -90,14 +99,16 @@ async def get_providers_availability(
result = []
for pid, info in all_providers.items():
status_info = health_status.get(pid, {"status": "unknown"})
result.append({
result.append(
{
"id": pid,
"name": info["name"],
"icon": info.get("icon", "🎬"),
"status": status_info["status"],
"enabled": pid not in disabled_providers,
"error": status_info.get("error")
})
"error": status_info.get("error"),
}
)
return result
@@ -107,10 +118,12 @@ async def toggle_provider(
provider_id: str,
response: Response,
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
session: Session = Depends(get_session),
):
"""Toggle a provider's enabled/disabled status"""
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
statement = select(AppSettingsTable).where(
AppSettingsTable.user_id == current_user.id
)
settings_obj = session.exec(statement).first()
if not settings_obj:
@@ -130,7 +143,14 @@ async def toggle_provider(
session.commit()
status_text = "activé" if enabled else "désactivé"
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"Fournisseur {provider_id} {status_text}", "type": "success"}})
response.headers["HX-Trigger"] = json.dumps(
{
"show-toast": {
"message": f"Fournisseur {provider_id} {status_text}",
"type": "success",
}
}
)
return {"id": provider_id, "enabled": enabled}
@@ -138,19 +158,18 @@ async def toggle_provider(
@router.get("/ui")
async def get_settings_ui(
request: Request,
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session)
current_user: Optional[User] = Depends(get_optional_user),
session: Session = Depends(get_session),
):
"""Return the settings UI fragment for HTMX"""
# Reuse existing endpoints logic
if current_user is None:
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
settings = await get_settings(current_user, session)
providers = await get_providers_availability(current_user, session)
return templates.TemplateResponse(
"components/settings_section.html",
{
"request": request,
"settings": settings,
"providers": providers
}
{"request": request, "settings": settings, "providers": providers},
)
+76 -16
View File
@@ -6,7 +6,15 @@ import re
import json
from typing import List, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response, Request, Query
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
HTTPException,
Response,
Request,
Query,
)
from fastapi.templating import Jinja2Templates
from app.download_manager import DownloadManager
@@ -20,7 +28,7 @@ from app.models.watchlist import (
WatchlistSettings,
WatchlistStatus,
)
from app.routers.router_auth import get_current_user_from_token
from app.routers.router_auth import get_current_user_from_token, get_optional_user
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
templates = Jinja2Templates(directory="templates")
@@ -28,6 +36,7 @@ templates = Jinja2Templates(directory="templates")
def get_download_manager() -> DownloadManager:
from main import download_manager
return download_manager
@@ -41,36 +50,54 @@ async def add_to_watchlist(
from main import watchlist_manager
try:
existing = watchlist_manager.get_by_anime_url(item_data.anime_url, current_user.id)
existing = watchlist_manager.get_by_anime_url(
item_data.anime_url, current_user.id
)
item = watchlist_manager.add(current_user.id, item_data)
msg = f"'{item.anime_title}' ajouté à la watchlist" if not existing else f"'{item.anime_title}' est déjà dans la watchlist"
msg = (
f"'{item.anime_title}' ajouté à la watchlist"
if not existing
else f"'{item.anime_title}' est déjà dans la watchlist"
)
toast_type = "success" if not existing else "info"
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": msg, "type": toast_type}})
response.headers["HX-Trigger"] = json.dumps(
{"show-toast": {"message": msg, "type": toast_type}}
)
return item
except Exception as e:
from logging import getLogger
logger = getLogger(__name__)
logger.error(f"Error adding to watchlist: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("", response_model=List[WatchlistItem])
@router.get("")
async def get_watchlist(
request: Request,
status: Optional[WatchlistStatus] = None,
html: bool = Query(False),
current_user: User = Depends(get_current_user_from_token),
current_user: Optional[User] = Depends(get_optional_user),
):
"""Get user's watchlist (supports HTML for HTMX)"""
from main import watchlist_manager
is_htmx = request.headers.get("HX-Request")
if current_user is None and (html or is_htmx):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
items = watchlist_manager.get_all(user_id=current_user.id, status=status)
if html or request.headers.get("HX-Request"):
if html or is_htmx:
return templates.TemplateResponse(
"components/watchlist_items_list.html",
{"request": request, "items": items}
"components/watchlist_items_list.html", {"request": request, "items": items}
)
return items
@@ -82,6 +109,7 @@ async def get_watchlist_settings(
):
"""Get global watchlist settings"""
from main import watchlist_manager
return watchlist_manager.get_settings()
@@ -97,9 +125,18 @@ async def update_watchlist_settings(
try:
updated_settings = watchlist_manager.update_settings(settings)
if auto_download_scheduler.is_running():
auto_download_scheduler.update_interval(updated_settings.check_interval_hours)
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
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -112,6 +149,7 @@ async def get_watchlist_item(
):
"""Get a specific watchlist item"""
from main import watchlist_manager
item = watchlist_manager.get_by_id(item_id)
if not item or item.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Item not found")
@@ -134,7 +172,14 @@ async def update_watchlist_item(
updated_item = watchlist_manager.update(item_id, update_data)
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"'{updated_item.anime_title}' mis à jour", "type": "success"}})
response.headers["HX-Trigger"] = json.dumps(
{
"show-toast": {
"message": f"'{updated_item.anime_title}' mis à jour",
"type": "success",
}
}
)
return updated_item
@@ -153,7 +198,14 @@ async def delete_from_watchlist(
title = item.anime_title
if watchlist_manager.delete(item_id):
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"'{title}' supprimé de la watchlist", "type": "info"}})
response.headers["HX-Trigger"] = json.dumps(
{
"show-toast": {
"message": f"'{title}' supprimé de la watchlist",
"type": "info",
}
}
)
# HTMX will handle removing the element if target is specified in the frontend
return Response(status_code=204)
@@ -170,7 +222,14 @@ async def check_watchlist_now(
from main import auto_download_scheduler
background_tasks.add_task(auto_download_scheduler.trigger_check_now)
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Vérification de la watchlist lancée en arrière-plan", "type": "info"}})
response.headers["HX-Trigger"] = json.dumps(
{
"show-toast": {
"message": "Vérification de la watchlist lancée en arrière-plan",
"type": "info",
}
}
)
return {"status": "success", "message": "Check triggered"}
@@ -181,4 +240,5 @@ async def get_watchlist_stats(
):
"""Get watchlist statistics for the user"""
from main import watchlist_manager
return watchlist_manager.get_stats(current_user.id)
+19 -2
View File
@@ -10,7 +10,7 @@ import uuid
from datetime import datetime
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -21,10 +21,18 @@ from app.models import DownloadTask, DownloadStatus
# Configure logging
logger = logging.getLogger(__name__)
PERMISSIONS_POLICY_VALUE = (
"accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), "
"camera=(), display-capture=(), document-domain=(), encrypted-media=(), "
"fullscreen=*, gamepad=(), geolocation=(), gyroscope=(), "
"magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=*, "
"publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), "
"usb=(), web-share=(), xr-spatial-tracking=()"
)
# Initialize FastAPI app
app = FastAPI(title="Ohm Stream Downloader")
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=[
@@ -40,6 +48,14 @@ app.add_middleware(
allow_headers=["*"],
)
@app.middleware("http")
async def permissions_policy_middleware(request: Request, call_next):
response: Response = await call_next(request)
response.headers["Permissions-Policy"] = PERMISSIONS_POLICY_VALUE
return response
# Initialize download manager
download_manager = DownloadManager(download_dir="downloads", max_parallel=3)
@@ -54,6 +70,7 @@ async def startup_event():
"""Initialize services on application startup"""
# Create database tables if they don't exist
from app.database import create_db_and_tables
create_db_and_tables()
logger.info("Database tables initialized")
+58 -98
View File
@@ -170,140 +170,106 @@ h1 {
.btn-watch { background: var(--primary); color: #000; }
.btn-download { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; }
/* Horizontal Rows (Netflix Style) */
.streaming-row {
/* Horizontal Scroll Row (Homepage) */
.home-row, .streaming-row, .recommendations-carousel, .releases-carousel {
display: flex;
gap: 20px;
gap: 16px;
overflow-x: auto;
padding: 20px 5px;
padding: 10px 0 20px;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
.streaming-row::-webkit-scrollbar {
height: 6px;
.home-row::-webkit-scrollbar, .streaming-row::-webkit-scrollbar, .recommendations-carousel::-webkit-scrollbar, .releases-carousel::-webkit-scrollbar {
height: 4px;
}
.streaming-row::-webkit-scrollbar-thumb {
.home-row::-webkit-scrollbar-thumb, .streaming-row::-webkit-scrollbar-thumb, .recommendations-carousel::-webkit-scrollbar-thumb, .releases-carousel::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.streaming-row::-webkit-scrollbar-thumb:hover {
.home-row::-webkit-scrollbar-thumb:hover, .streaming-row::-webkit-scrollbar-thumb:hover, .recommendations-carousel::-webkit-scrollbar-thumb:hover, .releases-carousel::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
/* Modern Card Design */
.anime-card {
flex: 0 0 220px;
/* Home Card */
.hc {
flex: 0 0 180px;
display: block;
background: var(--bg-card);
border-radius: var(--card-radius);
overflow: hidden;
transition: var(--transition);
position: relative;
border: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
text-decoration: none;
color: inherit;
}
.anime-card:hover {
transform: scale(1.05);
.hc:hover {
transform: scale(1.08);
z-index: 10;
border-color: var(--primary);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
}
.anime-poster {
.hc-poster {
position: relative;
padding-top: 150%;
background: #000;
}
.anime-poster img {
.hc-poster img {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
object-fit: cover;
transition: var(--transition);
}
.anime-rating-badge {
.hc-rating {
position: absolute;
top: 10px; right: 10px;
background: rgba(0, 0, 0, 0.8);
top: 8px; right: 8px;
background: rgba(0, 0, 0, 0.85);
color: #ffcc00;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.75rem;
padding: 2px 7px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 800;
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 204, 0, 0.2);
}
.anime-overlay {
.hc-play {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0) 100%);
bottom: 8px; right: 8px;
width: 32px; height: 32px;
border-radius: 50%;
background: var(--primary);
color: var(--bg-dark);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 15px;
align-items: center;
justify-content: center;
font-size: 0.75rem;
opacity: 0;
transition: var(--transition);
}
.anime-card:hover .anime-overlay { opacity: 1; }
.overlay-buttons {
display: flex;
gap: 10px;
justify-content: center;
.hc:hover .hc-play { opacity: 1; }
.hc-info {
padding: 10px;
}
/* Info Area */
.anime-info {
padding: 12px;
flex-grow: 1;
display: flex;
flex-direction: column;
.hc-src {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
color: var(--primary);
letter-spacing: 0.5px;
display: block;
margin-bottom: 2px;
}
.anime-title {
font-size: 0.95rem;
.hc-title {
font-size: 0.82rem;
font-weight: 600;
margin-bottom: 8px;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-main);
}
.anime-meta-tags {
display: flex;
gap: 5px;
margin-bottom: 12px;
}
.badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: var(--text-dim);
}
/* Action Buttons Grid */
.anime-card-buttons {
/* Responsive Grid for Search */
.anime-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 10px;
}
.btn-add-watchlist.followed {
border-color: var(--accent);
color: var(--accent);
background: rgba(0, 255, 136, 0.1);
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
}
/* Tabs UI */
@@ -640,23 +606,17 @@ h1 {
opacity: 0.15;
}
.htmx-indicator { display: none; }
.htmx-indicator.htmx-request { display: flex; align-items: center; gap: 8px; }
/* Section Containers */
.section-container {
margin-bottom: 50px;
}
/* Responsive Grid for Search */
.anime-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 30px;
}
@media (max-width: 768px) {
.anime-card { flex: 0 0 160px; }
.anime-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; }
.btn-card span { display: none; }
.btn-card { padding: 10px; }
.anime-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 12px; }
.hc { flex: 0 0 140px; }
.tabs { gap: 10px; }
.auth-panel { flex-direction: column; gap: 15px; text-align: center; }
}
+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
async function checkAuth() {
console.log('Checking authentication...');
const token = getToken();
if (!token) {
console.log('No token found');
window.dispatchEvent(new CustomEvent('auth-logout'));
return false;
}
@@ -56,20 +55,18 @@ async function checkAuth() {
if (response.ok) {
const data = await response.json();
console.log('Auth success:', data.user.username);
// Dispatch for Alpine
window.dispatchEvent(new CustomEvent('auth-success', {
detail: { username: data.user.full_name || data.user.username }
}));
return true;
} else {
console.log('Token invalid');
removeToken();
window.dispatchEvent(new CustomEvent('auth-logout'));
return false;
}
} catch (error) {
console.error('Auth check error:', error);
return false;
}
}
-1
View File
@@ -4,7 +4,6 @@
*/
async function loadDownloads() {
console.log('Legacy loadDownloads called - redirected to HTMX refresh');
if (typeof htmx !== 'undefined') {
htmx.trigger('#downloads-container-inner', 'refresh');
}
+14 -3
View File
@@ -18,6 +18,16 @@
[x-cloak] { display: none !important; }
</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) -->
<script src="/static/js/auth.js?v=1.10" defer></script>
<script src="/static/js/api.js?v=1.11" defer></script>
@@ -46,14 +56,15 @@
isAuthenticated: true,
username: '',
init() {
console.log('Global app state ready');
window.addEventListener('auth-success', (e) => {
console.log('Alpine auth-success received');
this.isAuthenticated = true;
this.username = e.detail.username;
});
window.addEventListener('auth-logout', () => {
this.isAuthenticated = false;
this.username = '';
});
window.addEventListener('set-tab', (e) => {
console.log('Alpine set-tab received:', e.detail.tab);
this.activeTab = e.detail.tab;
});
}
+10 -63
View File
@@ -1,71 +1,18 @@
{% macro anime_card(anime, in_watchlist=False, lang='vostfr') %}
<div class="anime-card" id="anime-{{ anime.url | hash }}">
<div class="anime-poster">
<div class="hc" id="anime-{{ anime.url | hash }}"
@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' %}
<img src="{{ poster }}"
alt="{{ anime.title }}"
loading="lazy"
referrerpolicy="no-referrer"
onerror="this.src='https://placehold.co/400x600/161625/00d9ff?text=Image+Error'; this.onerror=null;">
<img src="{{ poster }}" alt="{{ anime.title }}" loading="lazy" referrerpolicy="no-referrer"
onerror="this.src='https://placehold.co/400x600/161625/00d9ff?text=Error'; this.onerror=null;">
{% if anime.metadata and anime.metadata.rating %}
<div class="anime-rating-badge">
<i class="fas fa-star"></i> {{ anime.metadata.rating }}
</div>
<span class="hc-rating"><i class="fas fa-star"></i> {{ anime.metadata.rating }}</span>
{% endif %}
<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>
<span class="hc-play"><i class="fas fa-search"></i></span>
</div>
</div>
</div>
<div class="anime-info">
<h3 class="anime-title" title="{{ anime.title }}">{{ anime.title }}</h3>
<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 class="hc-info">
<span class="hc-src">{{ anime.provider_id or 'Anime' }}</span>
<span class="hc-title">{{ anime.title }}</span>
</div>
</div>
{% endmacro %}
+141 -23
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">
{% if results %}
{% for provider_id, items in results.items() %}
<div class="provider-section">
<h3 class="provider-title">{{ provider_id | upper }}</h3>
<div class="anime-grid">
{% for anime in items %}
{{ anime_card(anime, lang=settings.default_lang if settings else 'vostfr') }}
{% set _groups = namespace(items={}) %}
{% for pid, items in (results or {}).items() %}
{% for item in items %}
{% set _key = item.title | lower | trim %}
{% if _key not in _groups.items %}
{% set _ = _groups.items.update({_key: {
"title": item.title,
"cover": item.cover_image or (item.metadata.poster_image if item.metadata else "") or "",
"synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else "")[:300],
"rating": item.metadata.rating if item.metadata and item.metadata.rating else "",
"genres": item.metadata.genres if item.metadata and item.metadata.genres else [],
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
}}) %}
{% else %}
{% set _existing = _groups.items[_key] %}
{% if not _existing.cover and item.cover_image %}
{% set _ = _existing.update({"cover": item.cover_image}) %}
{% endif %}
{% if not _existing.synopsis and item.metadata and item.metadata.synopsis %}
{% set _ = _existing.update({"synopsis": item.metadata.synopsis[:300]}) %}
{% endif %}
{% if not _existing.rating and item.metadata and item.metadata.rating %}
{% set _ = _existing.update({"rating": item.metadata.rating}) %}
{% endif %}
{% set _ = _existing["providers"].append({"id": item.provider_id or pid, "url": item.url}) %}
{% endif %}
{% endfor %}
{% endfor %}
<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>
{% endfor %}
{% else %}
<div class="no-results">
<div class="sr-empty">
<i class="fas fa-search"></i>
<p>Aucun anime trouvé pour votre recherche.</p>
<p>Aucun anime trouve pour votre recherche.</p>
</div>
{% endif %}
</div>
<style>
.provider-section { margin-bottom: 40px; }
.provider-title {
color: var(--primary);
margin-bottom: 20px;
font-size: 1.2rem;
text-transform: uppercase;
letter-spacing: 1px;
.sr-list { display: flex; flex-direction: column; gap: 16px; }
.sr-card {
display: flex; gap: 20px;
background: var(--bg-card); border-radius: var(--card-radius);
padding: 20px; border: 1px solid rgba(255,255,255,0.05);
transition: var(--transition);
}
.no-results {
text-align: center;
padding: 100px 20px;
color: var(--text-dim);
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
.sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.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>
+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>
</div>
<button class="btn btn-secondary btn-small"
onclick="removeToken(); isAuthenticated = false"
hx-post="/api/auth/logout"
hx-on::after-request="isAuthenticated = false; window.location.reload()">
hx-on::after-request="window.location.href = '/login'">
🚪 Déconnexion
</button>
</div>
+4 -9
View File
@@ -1,9 +1,5 @@
<!-- Home Section: Premium Layout -->
<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-header">
<h2>🎯 Recommandé pour vous</h2>
@@ -16,12 +12,11 @@
<div id="recommendationsList"
hx-get="/api/recommendations"
hx-trigger="load delay:100ms"
class="streaming-row">
<div class="loading-spinner"></div>
class="home-row">
<div class="loading-placeholder"><div class="spinner"></div></div>
</div>
</div>
<!-- Latest Releases Row -->
<div class="section-container">
<div class="section-header">
<h2>🔥 Dernières sorties</h2>
@@ -34,8 +29,8 @@
<div id="releasesList"
hx-get="/api/releases/latest"
hx-trigger="load delay:300ms"
class="streaming-row">
<div class="loading-spinner"></div>
class="home-row">
<div class="loading-placeholder"><div class="spinner"></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>
+9 -49
View File
@@ -1,58 +1,18 @@
{% macro series_card(series, in_watchlist=False, lang='vf') %}
<div class="anime-card" id="series-{{ series.url | hash }}">
<div class="anime-poster">
<div class="ac" id="series-{{ series.url | hash }}">
<div class="ac-poster">
<img src="{{ series.cover_image or 'https://placehold.co/400x600/161625/ff6b6b?text=No+Image' }}"
alt="{{ series.title }}"
loading="lazy"
referrerpolicy="no-referrer"
onerror="this.src='https://placehold.co/400x600/161625/ff6b6b?text=Image+Error'; this.onerror=null;">
<div class="anime-overlay">
<div class="overlay-buttons">
<button class="btn btn-primary btn-circle"
alt="{{ series.title }}" loading="lazy" referrerpolicy="no-referrer"
onerror="this.src='https://placehold.co/400x600/161625/ff6b6b?text=Error'; this.onerror=null;">
<button class="ac-play"
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container"
hx-swap="innerHTML"
title="Play">
hx-target="#player-container" hx-swap="innerHTML">
<i class="fas fa-play"></i>
</button>
</div>
</div>
</div>
<div class="anime-info">
<h3 class="anime-title" title="{{ series.title }}">{{ series.title }}</h3>
<div class="anime-meta-tags">
<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 class="ac-info">
<span class="ac-provider" style="--ac-color: var(--secondary)">{{ series.provider_id | upper if series.provider_id else 'FS7' }}</span>
<h3 class="ac-title" title="{{ series.title }}">{{ series.title }}</h3>
</div>
</div>
{% endmacro %}
+115 -23
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">
{% if results %}
{% for provider_id, items in results.items() %}
<div class="provider-section">
<h3 class="provider-title">{{ provider_id | upper }}</h3>
<div class="anime-grid">
{% for series in items %}
{{ series_card(series, lang=settings.default_lang if settings else 'vf') }}
{% set _groups = namespace(items={}) %}
{% for pid, items in (results or {}).items() %}
{% for item in items %}
{% set _key = item.title | lower | trim %}
{% if _key not in _groups.items %}
{% set _ = _groups.items.update({_key: {
"title": item.title,
"cover": item.cover_image or "",
"synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else "")[:300],
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
}}) %}
{% else %}
{% set _existing = _groups.items[_key] %}
{% if not _existing.cover and item.cover_image %}
{% set _ = _existing.update({"cover": item.cover_image}) %}
{% endif %}
{% set _ = _existing["providers"].append({"id": item.provider_id or pid, "url": item.url}) %}
{% endif %}
{% endfor %}
{% endfor %}
<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>
{% endfor %}
{% else %}
<div class="no-results">
<div class="sr-empty">
<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>
{% endif %}
</div>
<style>
.provider-section { margin-bottom: 40px; }
.provider-title {
color: var(--secondary);
margin-bottom: 20px;
font-size: 1.2rem;
text-transform: uppercase;
letter-spacing: 1px;
.sr-list { display: flex; flex-direction: column; gap: 16px; }
.sr-card {
display: flex; gap: 20px;
background: var(--bg-card); border-radius: var(--card-radius);
padding: 20px; border: 1px solid rgba(255,255,255,0.05);
transition: var(--transition);
}
.no-results {
text-align: center;
padding: 100px 20px;
color: var(--text-dim);
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
.sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.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>
+2 -2
View File
@@ -35,7 +35,7 @@
Rechercher
</button>
</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>
<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
</button>
</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>
<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
## OVERVIEW
Pytest test suite for Ohm Stream Downloader with 18 test files covering unit and integration tests.
Pytest suite: 18+ test files, 5000+ lines. Auto-marked (unit/integration/asyncio) + manual markers (slow/network).
## STRUCTURE
```
tests/
├── conftest.py # Fixtures & pytest config
├── test_*.py # 18 test modules
├── test_api.py # FastAPI endpoints (integration)
├── test_auth.py # JWT authentication
├── test_download_manager.py # Download queue management
├── test_downloaders.py # Provider downloaders
├── test_anime_sama_*.py # Anime-Sama provider variants
├── test_favorites.py # Favorites management
├── test_french_manga.py # French-Manga provider
├── test_models.py # Pydantic model validation
── test_sonarr.py # Sonarr webhook integration
├── test_utils.py # Utility functions
├── test_watchlist.py # Auto-download watchlist
├── test_metadata_enrichment.py
├── test_translate_api.py
├── test_delete_and_restore.py
├── conftest.py # 301 lines: fixtures, markers, DB isolation, event loop
├── e2e/ # Playwright end-to-end tests
├── test_api.py # 627 lines — FastAPI endpoints (auto integration)
├── test_favorites.py # 564 lines — Favorites CRUD
├── test_sonarr.py # 513 lines — Sonarr webhook
├── test_metadata_enrichment.py # 442 lines — Kitsu API
├── test_download_manager.py # 395 lines — Download queue
├── test_downloaders.py # 341 lines — Provider scrapers
├── test_models.py # 321 lines — Pydantic models
├── test_utils.py # 246 lines — sanitize_filename, is_safe_filename
── test_watchlist.py # 178 lines — Auto-download watchlist
```
**Root-level tests** (legacy placement, NOT in tests/):
- `test_watchlist_simple.py`, `test_watchlist_e2e.py` — should be moved to tests/
## WHERE TO LOOK
| Need | File |
|------|------|
| Run all tests | `pytest` |
| Unit tests only | `pytest -m "unit"` |
| Integration tests | `pytest -m "integration"` (test_api.py auto-marked) |
| All tests | `pytest` |
| Unit only | `pytest -m "unit"` |
| 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` |
| API endpoints | `test_api.py` |
| Provider scrapers | `test_anime_sama_*.py`, `test_french_manga.py` |
@@ -40,25 +37,20 @@ tests/
## CONVENTIONS
**Markers** (auto-applied unless manual):
- `unit` - Default for non-api tests
- `integration` - test_api.py only
- `asyncio` - Auto-detected from coroutine functions
- `slow` - Manual: `@pytest.mark.slow`
- `network` - Manual: `@pytest.mark.network`
- `unit` Default for non-api tests
- `integration` test_api.py only
- `asyncio` Auto-detected from coroutine functions
- `slow` Manual: `@pytest.mark.slow`
- `network` Manual: `@pytest.mark.network`
**Naming**:
- Files: `test_*.py`
- Classes: `Test*` (e.g., `class TestSanitizeFilename:`)
- Functions: `test_*` (e.g., `def test_sanitize_simple_filename(self):`)
**DB isolation**: `conftest.py` forces `DATABASE_URL=sqlite://` in-memory. Tables auto-drop/recreate per test.
**Fixtures** (in conftest.py):
- `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
**Naming**: Files `test_*.py`, classes `Test*`, functions `test_*`.
**Run commands**:
- `pytest` - All tests with coverage
- `pytest -m "not slow"` - Skip slow tests
- `pytest --cov=app --cov-report=html` - HTML coverage report
**Config**: `pytest.ini` — asyncio_mode=auto, timeout=300s, coverage on app/.
## ANTI-PATTERNS
- 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