feat: fix auth, provider health checks, search, and redesign UI
- 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:
@@ -1,393 +1,156 @@
|
|||||||
# AGENTS.md - Agentic Coding Guidelines
|
# AGENTS.md — Ohm Stream Downloader
|
||||||
|
|
||||||
This file provides guidance for AI agents working in this repository.
|
FastAPI anime/series downloader with HTMX+Alpine.js frontend, SQLModel/SQLite DB,
|
||||||
|
3-tier scraper architecture, JWT auth, Sonarr webhooks, and auto-download scheduler.
|
||||||
|
|
||||||
## Quick Start
|
## COMMANDS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Setup
|
# Dev server
|
||||||
python3 -m venv venv && source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Run dev server
|
|
||||||
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
||||||
```
|
|
||||||
|
|
||||||
## Build, Lint & Test Commands
|
# --- Tests (pytest, configured in pytest.ini, asyncio_mode=auto) ---
|
||||||
|
|
||||||
### Running Tests
|
pytest # All tests (coverage + verbose by default)
|
||||||
|
pytest -m "unit" # Fast unit tests only
|
||||||
|
pytest -m "integration" # API integration tests
|
||||||
|
pytest -m "not slow" # CI default — excludes slow tests
|
||||||
|
pytest -m "network" # Tests requiring network access
|
||||||
|
|
||||||
```bash
|
# Single file / class / test
|
||||||
# All tests
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# With coverage (HTML report in htmlcov/)
|
|
||||||
pytest --cov=app --cov-report=html
|
|
||||||
|
|
||||||
# Unit only (fast)
|
|
||||||
pytest -m "unit"
|
|
||||||
|
|
||||||
# Integration tests only
|
|
||||||
pytest -m "integration"
|
|
||||||
|
|
||||||
# Exclude slow tests
|
|
||||||
pytest -m "not slow"
|
|
||||||
|
|
||||||
# Exclude network tests (mocked only)
|
|
||||||
pytest -m "not network"
|
|
||||||
|
|
||||||
# Verbose with print debugging
|
|
||||||
pytest -v -s
|
|
||||||
|
|
||||||
# Generate HTML report
|
|
||||||
pytest --html=report.html --self-contained-html
|
|
||||||
|
|
||||||
# Timeout per test (seconds)
|
|
||||||
pytest --timeout=30
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Single Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Specific file
|
|
||||||
pytest tests/test_sonarr.py -v
|
pytest tests/test_sonarr.py -v
|
||||||
|
|
||||||
# Specific class
|
|
||||||
pytest tests/test_sonarr.py::TestSonarrHandler -v
|
pytest tests/test_sonarr.py::TestSonarrHandler -v
|
||||||
|
|
||||||
# Specific test
|
|
||||||
pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
|
pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
|
||||||
|
|
||||||
# Pattern match
|
# Debug
|
||||||
pytest -k "test_download" -v
|
pytest -s # Show print() output
|
||||||
|
pytest --cov=app --cov-report=html # HTML coverage report in htmlcov/
|
||||||
|
|
||||||
|
# --- Lint & Format (ruff) ---
|
||||||
|
|
||||||
|
ruff check app/ # Lint
|
||||||
|
ruff format --check app/ # Format check (CI enforces this)
|
||||||
|
ruff format app/ # Auto-format
|
||||||
|
|
||||||
|
# --- Type Check ---
|
||||||
|
|
||||||
|
mypy app/ --ignore-missing-imports # Type check (CI enforces)
|
||||||
|
|
||||||
|
# --- DB Migrations ---
|
||||||
|
|
||||||
|
alembic revision --autogenerate -m "description"
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# --- Frontend (optional) ---
|
||||||
|
|
||||||
|
npm test # Vitest JS tests
|
||||||
|
npx playwright test # E2E browser tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Style
|
## CODE STYLE
|
||||||
|
|
||||||
### Imports (PEP 8 order)
|
### Imports
|
||||||
1. Standard library (`os`, `json`, `asyncio`)
|
Three groups separated by blank lines: stdlib → third-party → local (`app.*`).
|
||||||
2. Third-party (`httpx`, `beautifulsoup4`, `fastapi`)
|
Always absolute imports (no relative). No wildcard imports (`from X import *` enforced).
|
||||||
3. Local app (`app.config`, `app.utils`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
import os
|
|
||||||
import asyncio
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.models import DownloadTask, DownloadStatus
|
|
||||||
```
|
|
||||||
|
|
||||||
### Formatting
|
### Formatting
|
||||||
- **Line length**: 120 chars max
|
PEP 8, 120 chars max line length. Single quotes for strings, double quotes for docstrings.
|
||||||
- **Indentation**: 4 spaces
|
Ruff handles linting and formatting (no local config — CI-only).
|
||||||
- **Blank lines**: 2 between top-level, 1 between inline
|
|
||||||
|
|
||||||
### Type Annotations
|
### Types
|
||||||
- Use explicit types
|
Explicit type hints on all function signatures and return types.
|
||||||
- Use `Optional[X]` not `X | None`
|
Use `Optional[X]` from `typing`. Use `list[str]` / `dict[str, X]` (lowercase generics).
|
||||||
- Use `list[X]`, `dict[X, Y]`
|
Pydantic models for all API schemas. Return type annotations required on public methods.
|
||||||
|
|
||||||
```python
|
### Naming
|
||||||
# Good
|
- `snake_case` for functions, variables, constants
|
||||||
async def get_download_link(url: str, target_filename: Optional[str] = None) -> tuple[str, str]:
|
- `PascalCase` for classes and enums
|
||||||
results: list[dict[str, str]] = []
|
- `UPPER_SNAKE_CASE` for enum values (`DownloadStatus.PENDING`)
|
||||||
|
- `logger = logging.getLogger(__name__)` at module level
|
||||||
# Avoid
|
- `_` prefix for private methods (`_fetch_page`, `_sanitize`)
|
||||||
async def get_download_link(url, target_filename=None):
|
- `get_*` for factory functions (`get_downloader`, `get_anime_site`)
|
||||||
results = []
|
|
||||||
```
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
| Element | Convention | Example |
|
|
||||||
|---------|------------|---------|
|
|
||||||
| Modules | snake_case | `download_manager.py` |
|
|
||||||
| Classes | PascalCase | `DownloadManager` |
|
|
||||||
| Functions | snake_case | `get_download_link()` |
|
|
||||||
| Constants | UPPER_SNAKE | `MAX_PARALLEL_DOWNLOADS` |
|
|
||||||
| Variables | snake_case | `download_task` |
|
|
||||||
| Enums | PascalCase | `DownloadStatus` |
|
|
||||||
| Enum values | UPPER_SNAKE | `DownloadStatus.PENDING` |
|
|
||||||
|
|
||||||
### Async/Await
|
|
||||||
- Always use for I/O operations
|
|
||||||
- Close clients properly to avoid leaks
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def close(self):
|
|
||||||
await self.client.aclose()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
- Use try/except for recoverable errors
|
- `HTTPException` for API errors with proper status codes
|
||||||
- Raise specific exceptions (`HTTPException`, `ValueError`)
|
- `raise ValueError()` for business logic validation
|
||||||
- Never use empty except blocks
|
- `try/except` with logging — never bare `except:` (known tech debt exists)
|
||||||
- Log errors appropriately
|
- `response.raise_for_status()` for HTTP errors
|
||||||
|
- Never return `None` for missing URLs from downloaders — raise an exception
|
||||||
|
|
||||||
```python
|
### Docstrings
|
||||||
try:
|
Triple double quotes `"""`. Module docstrings describe purpose. Class docstrings describe
|
||||||
result = await client.get(url)
|
responsibility. Complex methods use Args/Returns sections. Not all methods require docstrings.
|
||||||
except httpx.TimeoutException:
|
|
||||||
logger.warning(f"Request timeout for {url}")
|
## ARCHITECTURE
|
||||||
raise HTTPException(status_code=504, detail="Request timeout")
|
|
||||||
|
```
|
||||||
|
main.py # App entry, middleware, startup, router registration
|
||||||
|
app/
|
||||||
|
├── routers/ # 11 APIRouter modules (one per feature domain)
|
||||||
|
├── downloaders/ # 3-tier: anime_sites/ → series_sites/ → video_players/
|
||||||
|
├── models/ # Pydantic/SQLModel (Base → Table → Schema pattern)
|
||||||
|
├── config.py # Pydantic Settings from .env
|
||||||
|
├── database.py # SQLModel engine (created at import time)
|
||||||
|
├── download_manager.py # Async queue, semaphore-based parallelism
|
||||||
|
├── auth.py # JWT + bcrypt, SQLModel user storage
|
||||||
|
├── providers.py # ANIME_PROVIDERS, SERIES_PROVIDERS, FILE_HOSTS registries
|
||||||
|
└── utils.py # sanitize_filename(), is_safe_filename()
|
||||||
|
templates/ # Jinja2 + HTMX + Alpine.js
|
||||||
|
static/js/ # Vanilla ES modules (no build step)
|
||||||
|
tests/ # pytest suite (conftest.py has shared fixtures)
|
||||||
|
config/ # Runtime JSON files (users, watchlist, sonarr)
|
||||||
|
alembic/ # DB migrations
|
||||||
```
|
```
|
||||||
|
|
||||||
### File Operations
|
## KEY CONVENTIONS
|
||||||
- Always sanitize filenames: `app.utils.sanitize_filename()`
|
|
||||||
- Validate paths: `app.utils.is_safe_filename()`
|
|
||||||
|
|
||||||
### Testing
|
- **URL pipe format**: `video_url|anime_page_url|episode_title` — metadata preserved through download pipeline
|
||||||
- Use pytest with pytest-asyncio
|
- **Module-level init**: `database.py` creates engine on import; `main.py` creates `download_manager` on import
|
||||||
- Mark tests: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.slow`, `@pytest.mark.network`
|
- **Async everywhere**: Always `async/await` for I/O — use `httpx.AsyncClient`, never `requests`
|
||||||
- Tests in `test_api.py` are auto-marked as integration, others as unit
|
- **Factory pattern**: `get_downloader(url)` routes anime → series → video player → generic
|
||||||
- Use fixtures from `tests/conftest.py`
|
- **Router deps**: `Depends(lambda: download_manager)`, `Depends(get_current_user_from_token)`, `Depends(lambda: templates)`
|
||||||
|
- **Dual storage**: Some features use JSON files (legacy) + SQLModel tables (newer)
|
||||||
|
- **Frontend**: No JS build step. HTMX for server interactions, Alpine.js for client state, Plyr.io for video
|
||||||
|
- **Circular import avoidance**: `episode_checker.py` uses lazy init (`set_download_manager()`)
|
||||||
|
|
||||||
```python
|
## ANTI-PATTERNS (DO NOT)
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_manager():
|
|
||||||
manager = DownloadManager(max_parallel=3)
|
|
||||||
assert manager.max_parallel == 3
|
|
||||||
|
|
||||||
# Mark slow tests
|
- Use sync `requests` — always `httpx.AsyncClient`
|
||||||
@pytest.mark.slow
|
- Return `None` for missing URLs from downloaders — raise an exception
|
||||||
async def test_full_download_flow():
|
- Skip `sanitize_filename()` on extracted filenames — path traversal risk
|
||||||
...
|
- Forget `await self.close()` in downloaders — resource leak
|
||||||
|
- Hardcode User-Agent in individual players — use base class headers
|
||||||
|
- Use `from X import *` — always explicit imports
|
||||||
|
- Import `download_manager` from `main.py` in app/ modules — causes circular imports
|
||||||
|
- Store secrets in `config/*.json` — use `.env`
|
||||||
|
- Use `as any`, `@ts-ignore` to suppress type errors (if adding TS)
|
||||||
|
|
||||||
# Mark tests requiring network
|
## TEST CONVENTIONS
|
||||||
@pytest.mark.network
|
|
||||||
async def test_external_api():
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security
|
- `tests/` directory with `conftest.py` for shared fixtures
|
||||||
- Never hardcode secrets - use environment variables
|
- Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.network`, `@pytest.mark.slow`
|
||||||
- Validate all inputs (URLs, filenames)
|
- `asyncio_mode = auto` — async test functions run without explicit marker
|
||||||
- Use HMAC for webhook verification when configured
|
- Test naming: `test_<verb>_<noun>` in `Test*` classes
|
||||||
- Limit CORS origins - never use `*` in production
|
- 300s timeout configured in pytest.ini; `testpaths = tests`
|
||||||
|
- Legacy test files at project root: `test_watchlist_simple.py`, `test_watchlist_e2e.py`
|
||||||
|
|
||||||
## Architecture Patterns
|
## ADDING NEW PROVIDERS
|
||||||
|
|
||||||
### Three-Tier Downloader Architecture
|
**Video player**: Create in `app/downloaders/video_players/`, inherit `BaseVideoPlayer`,
|
||||||
|
implement `can_handle()` + `get_download_link(url, target_filename=None)`, register in
|
||||||
|
`__init__.py`, add to `FILE_HOSTS` in `providers.py`.
|
||||||
|
|
||||||
The project uses a three-tier downloader system:
|
**Anime/series site**: Create in `app/downloaders/anime_sites/` or `series_sites/`, inherit
|
||||||
|
base class, implement `search_anime()` + `get_episodes()` + `get_anime_metadata()` +
|
||||||
|
`get_download_link()`, register in `__init__.py`, add to `providers.py`.
|
||||||
|
|
||||||
1. **Anime Catalogs** (`app/downloaders/anime_sites/`)
|
## NOTES
|
||||||
- `animesama.py` - Anime-Sama (primary)
|
|
||||||
- `animeultime.py` - Anime-Ultime
|
|
||||||
- `nekosama.py` - Neko-Sama
|
|
||||||
- `vostfree.py` - Vostfree
|
|
||||||
- `frenchmanga.py` - French-Manga
|
|
||||||
|
|
||||||
2. **Series Catalogs** (`app/downloaders/series_sites/`)
|
- Python 3.11+, CI tests on 3.11 and 3.12
|
||||||
- `fs7.py` - French Stream
|
- No `pyproject.toml` — uses `requirements.txt` with exact version pinning
|
||||||
|
- `GEMINI.md` and `CLAUDE.md` exist with tool-specific instructions (merge if conflicting)
|
||||||
3. **Video Players** (`app/downloaders/video_players/`)
|
- French-language project (animes, séries, VOSTFR) but all code and comments in English
|
||||||
- `sibnet.py`, `doodstream.py`, `vidmoly.py`, `uqload.py`
|
- ~20 empty `except:` blocks in downloaders/tests — known tech debt
|
||||||
- `uptobox.py`, `unfichier.py`, `rapidfile.py`
|
- `JWT_SECRET_KEY` min 32 chars, default rejected at startup; generate via `Settings.generate_secret()`
|
||||||
- `sendvid.py`, `lpayer.py`, `vidzy.py`, `luluv.py`
|
- Sub-AGENTS.md files exist in `app/`, `app/routers/`, `app/downloaders/`, `app/models/`,
|
||||||
- `oneupload.py`, `smoothpre.py`
|
`app/downloaders/anime_sites/`, `app/downloaders/video_players/` for deeper context
|
||||||
|
|
||||||
Each tier has a base class and factory pattern. When adding providers:
|
|
||||||
1. Inherit from appropriate base class (`base.py`)
|
|
||||||
2. Implement required methods (`search_anime`, `get_episodes`, `get_download_link`)
|
|
||||||
3. Register in `app/providers.py`
|
|
||||||
4. Add URL detection patterns
|
|
||||||
|
|
||||||
**URL Convention**: Pipe-separated format preserves metadata:
|
|
||||||
```
|
|
||||||
video_url|anime_page_url|episode_title
|
|
||||||
```
|
|
||||||
|
|
||||||
### Core Modules
|
|
||||||
|
|
||||||
| Module | Purpose |
|
|
||||||
|--------|---------|
|
|
||||||
| `app/watchlist.py` | Episode tracking & auto-download |
|
|
||||||
| `app/auto_download_scheduler.py` | APScheduler for periodic checks |
|
|
||||||
| `app/episode_checker.py` | New episode detection |
|
|
||||||
| `app/sonarr_handler.py` | Sonarr webhook integration |
|
|
||||||
| `app/recommendation_engine.py` | Personalized anime recommendations |
|
|
||||||
| `app/favorites.py` | User favorites management |
|
|
||||||
| `app/auth.py` | JWT authentication |
|
|
||||||
| `app/download_manager.py` | Download queue management |
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `main.py` | FastAPI app, all API endpoints |
|
|
||||||
| `app/config.py` | Pydantic Settings configuration |
|
|
||||||
| `app/download_manager.py` | Download queue & task management |
|
|
||||||
| `app/utils.py` | `sanitize_filename`, `is_safe_filename` |
|
|
||||||
| `app/auth.py` | JWT auth, user management |
|
|
||||||
| `app/providers.py` | Provider definitions & URL detection |
|
|
||||||
| `app/models/__init__.py` | Core Pydantic models |
|
|
||||||
| `app/models/watchlist.py` | Watchlist models |
|
|
||||||
| `app/models/sonarr.py` | Sonarr integration models |
|
|
||||||
| `app/models/auth.py` | Authentication models |
|
|
||||||
|
|
||||||
## Frontend Architecture
|
|
||||||
|
|
||||||
### JavaScript Modules (`static/js/`)
|
|
||||||
| Module | Purpose |
|
|
||||||
|--------|---------|
|
|
||||||
| `main.js` | Application entry point |
|
|
||||||
| `api.js` | API client functions |
|
|
||||||
| `auth.js` | Authentication handling |
|
|
||||||
| `tabs.js` | Tab navigation |
|
|
||||||
| `anime.js` | Anime search & display |
|
|
||||||
| `anime-details.js` | Anime detail views |
|
|
||||||
| `watchlist.js` | Watchlist API calls |
|
|
||||||
| `watchlist-ui.js` | Watchlist UI rendering |
|
|
||||||
| `downloads.js` | Download management UI |
|
|
||||||
| `recommendations.js` | Recommendations display |
|
|
||||||
| `series-search.js` | TV series search |
|
|
||||||
| `utils.js` | Utility functions |
|
|
||||||
|
|
||||||
### Templates (`templates/`)
|
|
||||||
| Template | Purpose |
|
|
||||||
|----------|---------|
|
|
||||||
| `base.html` | Base layout with CSS/JS imports |
|
|
||||||
| `index.html` | Main SPA interface |
|
|
||||||
| `login.html` | Login/register page |
|
|
||||||
| `watchlist.html` | Watchlist management page |
|
|
||||||
| `player.html` | Video player page |
|
|
||||||
| `components/` | Reusable HTML components |
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
- Use `.env` from `.env.example`
|
|
||||||
- `JWT_SECRET_KEY` must change in production
|
|
||||||
- Config files stored in `config/`:
|
|
||||||
- `users.json` - User database
|
|
||||||
- `watchlist.json` - Watchlist data
|
|
||||||
- `watchlist_settings.json` - Auto-download settings
|
|
||||||
- `sonarr.json` - Sonarr integration config
|
|
||||||
- `sonarr_mappings.json` - Series to anime mappings
|
|
||||||
|
|
||||||
## API Endpoints Overview
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- `POST /api/auth/register` - Register new user
|
|
||||||
- `POST /api/auth/login` - Login, get JWT token
|
|
||||||
- `GET /api/auth/me` - Get current user info
|
|
||||||
- `POST /api/auth/logout` - Logout (client-side)
|
|
||||||
|
|
||||||
### Downloads
|
|
||||||
- `POST /api/download` - Create download task
|
|
||||||
- `GET /api/downloads` - List all downloads
|
|
||||||
- `GET /api/download/{task_id}` - Get download status
|
|
||||||
- `POST /api/download/{task_id}/pause` - Pause download
|
|
||||||
- `POST /api/download/{task_id}/resume` - Resume download
|
|
||||||
- `DELETE /api/download/{task_id}` - Cancel/delete download
|
|
||||||
- `GET /api/download/{task_id}/file` - Download completed file
|
|
||||||
|
|
||||||
### Anime Search & Metadata
|
|
||||||
- `GET /api/anime/search` - Search across all anime providers
|
|
||||||
- `GET /api/series/search` - Search TV series providers
|
|
||||||
- `GET /api/anime/metadata` - Get detailed anime metadata
|
|
||||||
- `GET /api/anime/episodes` - Get episode list
|
|
||||||
- `GET /api/anime/seasons` - Get available seasons
|
|
||||||
- `POST /api/anime/download-season` - Download all episodes
|
|
||||||
|
|
||||||
### Watchlist
|
|
||||||
- `GET /api/watchlist` - List watchlist items
|
|
||||||
- `POST /api/watchlist` - Add to watchlist
|
|
||||||
- `PUT /api/watchlist/{item_id}` - Update watchlist item
|
|
||||||
- `DELETE /api/watchlist/{item_id}` - Remove from watchlist
|
|
||||||
- `GET /api/watchlist/settings` - Get auto-download settings
|
|
||||||
- `PUT /api/watchlist/settings` - Update settings
|
|
||||||
- `POST /api/watchlist/check` - Trigger manual episode check
|
|
||||||
|
|
||||||
### Favorites
|
|
||||||
- `GET /api/favorites` - List favorites
|
|
||||||
- `POST /api/favorites` - Add to favorites
|
|
||||||
- `DELETE /api/favorites/{anime_id}` - Remove from favorites
|
|
||||||
- `POST /api/favorites/toggle` - Toggle favorite status
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
- `GET /api/recommendations` - Get personalized recommendations
|
|
||||||
- `GET /api/releases/latest` - Get latest releases
|
|
||||||
- `GET /api/releases/seasonal` - Get seasonal anime
|
|
||||||
|
|
||||||
### Sonarr Integration
|
|
||||||
- `POST /api/sonarr/webhook` - Receive Sonarr webhooks
|
|
||||||
- `GET /api/sonarr/mappings` - List Sonarr mappings
|
|
||||||
- `POST /api/sonarr/mappings` - Create mapping
|
|
||||||
- `DELETE /api/sonarr/mappings/{series_id}` - Delete mapping
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### Core
|
|
||||||
- `fastapi` - Web framework
|
|
||||||
- `uvicorn` - ASGI server
|
|
||||||
- `httpx` - Async HTTP client
|
|
||||||
- `aiohttp` - Alternative HTTP client
|
|
||||||
- `pydantic` / `pydantic-settings` - Data validation & settings
|
|
||||||
|
|
||||||
### Scraping & Parsing
|
|
||||||
- `beautifulsoup4` - HTML parsing
|
|
||||||
- `lxml` - XML/HTML parser
|
|
||||||
- `jieba` - Chinese text segmentation
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- `python-jose` - JWT handling
|
|
||||||
- `passlib[bcrypt]` - Password hashing
|
|
||||||
|
|
||||||
### Scheduler
|
|
||||||
- `apscheduler` - Job scheduling for auto-downloads
|
|
||||||
|
|
||||||
### Cryptography
|
|
||||||
- `pycryptodome` - AES decryption for video players
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- `pytest` + `pytest-asyncio` - Async test support
|
|
||||||
- `pytest-cov` - Coverage reporting
|
|
||||||
- `pytest-mock` - Mocking utilities
|
|
||||||
- `pytest-timeout` - Test timeout protection
|
|
||||||
- `pytest-html` - HTML test reports
|
|
||||||
|
|
||||||
|
|
||||||
## CI/CD
|
|
||||||
|
|
||||||
### GitHub Actions
|
|
||||||
|
|
||||||
This project uses GitHub Actions for continuous integration. The workflow is defined in `.github/workflows/ci.yml`.
|
|
||||||
|
|
||||||
**Workflow Features:**
|
|
||||||
- Runs on push and pull requests to `main` and `dev` branches
|
|
||||||
- Tests on Python 3.11 and 3.12
|
|
||||||
- Excludes slow tests by default (`-m "not slow"`)
|
|
||||||
- Generates HTML coverage reports
|
|
||||||
- Runs linting with ruff
|
|
||||||
- Runs type checking with mypy
|
|
||||||
|
|
||||||
**Artifacts:**
|
|
||||||
- Coverage reports are uploaded as artifacts after each run
|
|
||||||
- Access via the "Actions" tab on GitHub
|
|
||||||
|
|
||||||
**Running locally:**
|
|
||||||
```bash
|
|
||||||
# Run tests (excluding slow tests)
|
|
||||||
pytest -m "not slow" --cov=app --cov-report=html
|
|
||||||
|
|
||||||
# Run all tests including slow ones
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# Run only unit tests
|
|
||||||
pytest -m "unit"
|
|
||||||
|
|
||||||
# Run only integration tests
|
|
||||||
pytest -m "integration"
|
|
||||||
|
|
||||||
# Run linting
|
|
||||||
pip install ruff && ruff check app/
|
|
||||||
|
|
||||||
# Run type checking
|
|
||||||
pip install mypy && mypy app/
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# App Core (app/)
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
FastAPI application core — config, auth, download management, providers, and business logic. Routes are in `routers/`, scrapers in `downloaders/`, models in `models/`.
|
||||||
|
|
||||||
|
## STRUCTURE
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── config.py # Pydantic Settings (loads .env)
|
||||||
|
├── database.py # SQLModel engine (created at import time)
|
||||||
|
├── download_manager.py # Async download queue (semaphore-based)
|
||||||
|
├── auth.py # JWT + bcrypt, JSON-backed UserManager
|
||||||
|
├── providers.py # ANIME_PROVIDERS, FILE_HOSTS registries
|
||||||
|
├── utils.py # sanitize_filename(), is_safe_filename()
|
||||||
|
├── watchlist.py # WatchlistManager (JSON + SQLModel hybrid)
|
||||||
|
├── episode_checker.py # New episode detection for watchlist
|
||||||
|
├── auto_download_scheduler.py # APScheduler periodic checks
|
||||||
|
├── sonarr_handler.py # Sonarr webhook processing
|
||||||
|
├── favorites.py # FavoritesManager (JSON-backed)
|
||||||
|
├── recommendation_engine.py # Download history analysis
|
||||||
|
├── recommendations.py # Latest releases fetcher
|
||||||
|
└── kitsu_api.py # Kitsu anime metadata API
|
||||||
|
```
|
||||||
|
|
||||||
|
## WHERE TO LOOK
|
||||||
|
|
||||||
|
| Need | File | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| Add env var | `config.py` | Add to Settings class, update `.env.example` |
|
||||||
|
| Add provider domain | `providers.py` | ANIME_PROVIDERS or FILE_HOSTS dict |
|
||||||
|
| Download queue logic | `download_manager.py` | Semaphore-limited parallel downloads |
|
||||||
|
| Auth/token logic | `auth.py` | UserManager, JWT create/verify |
|
||||||
|
| Filename safety | `utils.py` | ALWAYS use sanitize_filename() |
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
|
||||||
|
**Circular import avoidance**: `episode_checker.py` uses lazy init (`set_download_manager()`) — called from `main.py:47` after both modules loaded.
|
||||||
|
|
||||||
|
**Dual storage**: Some features use JSON files (favorites, users) + SQLModel tables (watchlist, sonarr mappings). JSON is legacy, SQLModel is newer.
|
||||||
|
|
||||||
|
**Module-level side effects**: `database.py` creates engine on import. `main.py` creates `download_manager` on import (line 44). `restore_completed_downloads()` runs at module level (line 108).
|
||||||
|
|
||||||
|
## ANTI-PATTERNS
|
||||||
|
|
||||||
|
- Do NOT import `download_manager` from `main.py` in other app/ modules — causes circular imports
|
||||||
|
- Do NOT use `requests` — always `httpx.AsyncClient`
|
||||||
|
- Do NOT store secrets in `config/*.json` — use `.env`
|
||||||
+55
-43
@@ -32,7 +32,8 @@ class UserManager:
|
|||||||
|
|
||||||
def get_user(self, username: str) -> Optional[UserTable]:
|
def get_user(self, username: str) -> Optional[UserTable]:
|
||||||
"""Get user by username"""
|
"""Get user by username"""
|
||||||
from app.models.watchlist import WatchlistItemTable # Force registration
|
from app.models.watchlist import WatchlistItemTable # Force registration
|
||||||
|
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
statement = select(UserTable).where(UserTable.username == username)
|
statement = select(UserTable).where(UserTable.username == username)
|
||||||
return session.exec(statement).first()
|
return session.exec(statement).first()
|
||||||
@@ -44,7 +45,11 @@ class UserManager:
|
|||||||
return session.exec(statement).first()
|
return session.exec(statement).first()
|
||||||
|
|
||||||
def create_user(
|
def create_user(
|
||||||
self, username: str, password: str, email: str = None, full_name: str = None
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
full_name: Optional[str] = None,
|
||||||
) -> UserTable:
|
) -> UserTable:
|
||||||
"""Create a new user"""
|
"""Create a new user"""
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
@@ -68,7 +73,7 @@ class UserManager:
|
|||||||
full_name=full_name,
|
full_name=full_name,
|
||||||
hashed_password=hashed_password,
|
hashed_password=hashed_password,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
created_at=datetime.now()
|
created_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(user)
|
session.add(user)
|
||||||
@@ -105,11 +110,11 @@ class UserManager:
|
|||||||
db_user = session.get(UserTable, user_id)
|
db_user = session.get(UserTable, user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
if hasattr(db_user, key):
|
if hasattr(db_user, key):
|
||||||
setattr(db_user, key, value)
|
setattr(db_user, key, value)
|
||||||
|
|
||||||
session.add(db_user)
|
session.add(db_user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_user)
|
session.refresh(db_user)
|
||||||
@@ -191,9 +196,10 @@ REFRESH_TOKENS_FILE = "config/refresh_tokens.json"
|
|||||||
def _load_refresh_tokens() -> Dict[str, dict]:
|
def _load_refresh_tokens() -> Dict[str, dict]:
|
||||||
"""Load refresh tokens from file"""
|
"""Load refresh tokens from file"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if os.path.exists(REFRESH_TOKENS_FILE):
|
if os.path.exists(REFRESH_TOKENS_FILE):
|
||||||
with open(REFRESH_TOKENS_FILE, 'r', encoding='utf-8') as f:
|
with open(REFRESH_TOKENS_FILE, "r", encoding="utf-8") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading refresh tokens: {e}")
|
logger.error(f"Error loading refresh tokens: {e}")
|
||||||
@@ -203,9 +209,10 @@ def _load_refresh_tokens() -> Dict[str, dict]:
|
|||||||
def _save_refresh_tokens(tokens: Dict[str, dict]):
|
def _save_refresh_tokens(tokens: Dict[str, dict]):
|
||||||
"""Save refresh tokens to file"""
|
"""Save refresh tokens to file"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True)
|
os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True)
|
||||||
with open(REFRESH_TOKENS_FILE, 'w', encoding='utf-8') as f:
|
with open(REFRESH_TOKENS_FILE, "w", encoding="utf-8") as f:
|
||||||
json.dump(tokens, f, indent=2, ensure_ascii=False, default=str)
|
json.dump(tokens, f, indent=2, ensure_ascii=False, default=str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving refresh tokens: {e}")
|
logger.error(f"Error saving refresh tokens: {e}")
|
||||||
@@ -216,59 +223,60 @@ def _get_jwt_config() -> dict:
|
|||||||
"SECRET_KEY": settings.jwt_secret_key,
|
"SECRET_KEY": settings.jwt_secret_key,
|
||||||
"ALGORITHM": settings.jwt_algorithm,
|
"ALGORITHM": settings.jwt_algorithm,
|
||||||
"ACCESS_TOKEN_EXPIRE_MINUTES": settings.access_token_expire_minutes,
|
"ACCESS_TOKEN_EXPIRE_MINUTES": settings.access_token_expire_minutes,
|
||||||
"REFRESH_TOKEN_EXPIRE_DAYS": 30
|
"REFRESH_TOKEN_EXPIRE_DAYS": 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
|
def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Create both access and refresh tokens.
|
Create both access and refresh tokens.
|
||||||
|
|
||||||
Access token: short-lived (24 hours by default)
|
Access token: short-lived (24 hours by default)
|
||||||
Refresh token: long-lived (30 days by default)
|
Refresh token: long-lived (30 days by default)
|
||||||
|
|
||||||
Returns: (access_token, refresh_token)
|
Returns: (access_token, refresh_token)
|
||||||
"""
|
"""
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
jwt_config = _get_jwt_config()
|
jwt_config = _get_jwt_config()
|
||||||
|
|
||||||
# Create access token (short-lived)
|
# Create access token (short-lived)
|
||||||
access_expire = datetime.utcnow() + timedelta(minutes=jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"])
|
access_expire = datetime.utcnow() + timedelta(
|
||||||
|
minutes=jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"]
|
||||||
|
)
|
||||||
access_data = data.copy()
|
access_data = data.copy()
|
||||||
access_data.update({"exp": access_expire, "type": "access"})
|
access_data.update({"exp": access_expire, "type": "access"})
|
||||||
access_token = jwt.encode(
|
access_token = jwt.encode(
|
||||||
access_data,
|
access_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
|
||||||
jwt_config["SECRET_KEY"],
|
|
||||||
algorithm=jwt_config["ALGORITHM"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create refresh token (long-lived)
|
# Create refresh token (long-lived)
|
||||||
refresh_expire = datetime.utcnow() + timedelta(days=jwt_config["REFRESH_TOKEN_EXPIRE_DAYS"])
|
refresh_expire = datetime.utcnow() + timedelta(
|
||||||
|
days=jwt_config["REFRESH_TOKEN_EXPIRE_DAYS"]
|
||||||
|
)
|
||||||
# Generate a unique token ID
|
# Generate a unique token ID
|
||||||
token_id = secrets.token_urlsafe(32)
|
token_id = secrets.token_urlsafe(32)
|
||||||
refresh_data = {
|
refresh_data = {
|
||||||
"sub": data["sub"],
|
"sub": data["sub"],
|
||||||
"token_id": token_id,
|
"token_id": token_id,
|
||||||
"exp": refresh_expire,
|
"exp": refresh_expire,
|
||||||
"type": "refresh"
|
"type": "refresh",
|
||||||
}
|
}
|
||||||
refresh_token = jwt.encode(
|
refresh_token = jwt.encode(
|
||||||
refresh_data,
|
refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
|
||||||
jwt_config["SECRET_KEY"],
|
|
||||||
algorithm=jwt_config["ALGORITHM"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store refresh token mapping
|
# Store refresh token mapping
|
||||||
refresh_tokens = _load_refresh_tokens()
|
refresh_tokens = _load_refresh_tokens()
|
||||||
refresh_tokens[token_id] = {
|
refresh_tokens[token_id] = {
|
||||||
"username": data["sub"],
|
"username": data["sub"],
|
||||||
"token_id": token_id,
|
"token_id": token_id,
|
||||||
"created_at": datetime.now().isoformat(),
|
"created_at": datetime.now().isoformat(),
|
||||||
"expires_at": refresh_expire.isoformat()
|
"expires_at": refresh_expire.isoformat(),
|
||||||
}
|
}
|
||||||
_save_refresh_tokens(refresh_tokens)
|
_save_refresh_tokens(refresh_tokens)
|
||||||
|
|
||||||
return access_token, refresh_token
|
return access_token, refresh_token
|
||||||
|
|
||||||
|
|
||||||
@@ -279,35 +287,37 @@ def verify_refresh_token(token: str) -> Optional[str]:
|
|||||||
"""
|
"""
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from jose.exceptions import JWTError
|
from jose.exceptions import JWTError
|
||||||
|
|
||||||
jwt_config = _get_jwt_config()
|
jwt_config = _get_jwt_config()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]])
|
payload = jwt.decode(
|
||||||
|
token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]
|
||||||
|
)
|
||||||
|
|
||||||
# Verify this is a refresh token
|
# Verify this is a refresh token
|
||||||
if payload.get("type") != "refresh":
|
if payload.get("type") != "refresh":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
token_id = payload.get("token_id")
|
token_id = payload.get("token_id")
|
||||||
|
|
||||||
if not username or not token_id:
|
if not username or not token_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if token exists in storage
|
# Check if token exists in storage
|
||||||
refresh_tokens = _load_refresh_tokens()
|
refresh_tokens = _load_refresh_tokens()
|
||||||
stored_token = refresh_tokens.get(token_id)
|
stored_token = refresh_tokens.get(token_id)
|
||||||
|
|
||||||
if not stored_token:
|
if not stored_token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Verify token hasn't been revoked or expired
|
# Verify token hasn't been revoked or expired
|
||||||
if stored_token.get("revoked"):
|
if stored_token.get("revoked"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return username
|
return username
|
||||||
|
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -319,24 +329,26 @@ def revoke_refresh_token(token: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from jose.exceptions import JWTError
|
from jose.exceptions import JWTError
|
||||||
|
|
||||||
jwt_config = _get_jwt_config()
|
jwt_config = _get_jwt_config()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]])
|
payload = jwt.decode(
|
||||||
|
token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]
|
||||||
|
)
|
||||||
token_id = payload.get("token_id")
|
token_id = payload.get("token_id")
|
||||||
|
|
||||||
if not token_id:
|
if not token_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
refresh_tokens = _load_refresh_tokens()
|
refresh_tokens = _load_refresh_tokens()
|
||||||
if token_id in refresh_tokens:
|
if token_id in refresh_tokens:
|
||||||
refresh_tokens[token_id]["revoked"] = True
|
refresh_tokens[token_id]["revoked"] = True
|
||||||
refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat()
|
refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat()
|
||||||
_save_refresh_tokens(refresh_tokens)
|
_save_refresh_tokens(refresh_tokens)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Anime Sites Downloaders
|
# Anime Sites (app/downloaders/anime_sites/)
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
Handlers for French anime streaming catalogs that provide metadata and episode listings, delegating actual video extraction to video player handlers.
|
Handlers for French anime streaming catalogs that provide metadata and episode listings, delegating actual video extraction to video player handlers.
|
||||||
@@ -7,8 +7,8 @@ Handlers for French anime streaming catalogs that provide metadata and episode l
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `base.py` | Abstract `BaseAnimeSite` class defining the interface all anime sites implement |
|
| `base.py` | Abstract `BaseAnimeSite` class defining the interface |
|
||||||
| `animesama.py` | Primary provider with dynamic domain switching, multiple video player extraction |
|
| `animesama.py` | Primary provider — dynamic domain switching, multiple video player extraction |
|
||||||
| `nekosama.py` | Neko-Sama / Gupy integration (metadata-only, no direct downloads) |
|
| `nekosama.py` | Neko-Sama / Gupy integration (metadata-only, no direct downloads) |
|
||||||
| `animeultime.py` | Anime-Ultime catalog handler |
|
| `animeultime.py` | Anime-Ultime catalog handler |
|
||||||
| `vostfree.py` | Vostfree catalog handler |
|
| `vostfree.py` | Vostfree catalog handler |
|
||||||
@@ -16,26 +16,26 @@ Handlers for French anime streaming catalogs that provide metadata and episode l
|
|||||||
|
|
||||||
## CONVENTIONS
|
## CONVENTIONS
|
||||||
|
|
||||||
### Interface Contract
|
**Interface contract** — each site implements from `BaseAnimeSite`:
|
||||||
Each site must implement four async methods from `BaseAnimeSite`:
|
- `can_handle(url)` — URL pattern matching
|
||||||
- `can_handle(url: str) -> bool` — URL pattern matching
|
- `search_anime(query, lang)` → `[{title, url, cover_image}]`
|
||||||
- `search_anime(query, lang) -> list[dict]` — Returns `{title, url, cover_image}`
|
- `get_episodes(anime_url, lang)` → `[{episode_number, url, title, host}]`
|
||||||
- `get_episodes(anime_url, lang) -> list[dict]` — Returns `{episode_number, url, title, host}`
|
- `get_anime_metadata(anime_url)` → `{synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status}`
|
||||||
- `get_anime_metadata(anime_url) -> dict` — Returns `{synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status}`
|
- `get_download_link(url)` → `(video_player_url, filename)`
|
||||||
- `get_download_link(url) -> tuple[str, str]` — Returns `(video_player_url, filename)`
|
|
||||||
|
|
||||||
### Key Patterns
|
**Key patterns**:
|
||||||
- **Pipe-separated URLs**: `video_url|anime_page_url|episode_title` — preserves context across extraction
|
- Pipe-separated URLs: `video_url|anime_page_url|episode_title`
|
||||||
- **Language parameter**: `lang="vostfr"` or `"vf"` — controls which episodes to return
|
- Language param: `lang="vostfr"` or `"vf"`
|
||||||
- **Video player delegation**: Anime sites return player URLs (vidmoly, sendvid, sibnet, lpayer), not direct downloads
|
- Video player delegation: returns player URLs (vidmoly, sendvid, etc.), NOT direct downloads
|
||||||
- **Filename generation**: `{anime_name} - S{season} - {episode}.mp4` format
|
- Filename format: `{anime_name} - S{season} - {episode}.mp4`
|
||||||
- **HTTP headers**: Browser UA and referer required to avoid blocking
|
- Browser UA + referer headers required
|
||||||
|
|
||||||
### Domain Detection
|
**Domain detection**: `AnimeSamaDownloader` fetches current domain from `anime-sama.pw` dynamically. Uses fallback chain for video extraction.
|
||||||
- `AnimeSamaDownloader` fetches current domain from `anime-sama.pw` dynamically
|
|
||||||
- Uses fallback chain for video extraction: detected player → cached player → priority list
|
|
||||||
|
|
||||||
### Error Handling
|
**Error handling**: Raise `Exception` with descriptive message. Log at `debug` for expected failures, `error` for unexpected. Validate URLs with `_test_video_url()` before returning.
|
||||||
- Raise `Exception` with descriptive message on failure
|
|
||||||
- Log at appropriate level (`debug` for expected failures, `error` for unexpected)
|
## ANTI-PATTERNS
|
||||||
- Validate extracted URLs with `_test_video_url()` before returning
|
|
||||||
|
- Do NOT return direct download URLs from anime sites — return player URLs
|
||||||
|
- Do NOT skip URL validation — use `_test_video_url()`
|
||||||
|
- 5 empty `except:` blocks in `animesama.py` — known tech debt, silently swallow failures
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,10 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
|
|
||||||
BASE_DOMAINS = ["anime-ultime.com", "anime-ultime.net", "www.anime-ultime.net"]
|
BASE_DOMAINS = ["anime-ultime.com", "anime-ultime.net", "www.anime-ultime.net"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.id = "anime-ultime"
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||||
|
|
||||||
@@ -24,58 +28,79 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
final_url = str(response.url)
|
final_url = str(response.url)
|
||||||
|
|
||||||
# Parse the page
|
# Parse the page
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
# Method 0: Look for og:video meta tag (most reliable for anime-ultime)
|
# Method 0: Look for og:video meta tag (most reliable for anime-ultime)
|
||||||
og_video = soup.find('meta', property='og:video')
|
og_video = soup.find("meta", property="og:video")
|
||||||
if og_video and og_video.get('content'):
|
if og_video and og_video.get("content"):
|
||||||
video_url = og_video['content']
|
video_url = og_video["content"]
|
||||||
if video_url.endswith('.mp4'):
|
if video_url.endswith(".mp4"):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
print(f"[ANIME-ULTIME] Found og:video link: {video_url}")
|
print(f"[ANIME-ULTIME] Found og:video link: {video_url}")
|
||||||
return video_url, filename
|
return video_url, filename
|
||||||
|
|
||||||
# Method 1: Look for direct download links (DDL)
|
# Method 1: Look for direct download links (DDL)
|
||||||
# Anime-Ultime often uses links to file hosts
|
# Anime-Ultime often uses links to file hosts
|
||||||
download_links = soup.find_all('a', href=True)
|
download_links = soup.find_all("a", href=True)
|
||||||
for link in download_links:
|
for link in download_links:
|
||||||
href = link['href']
|
href = link["href"]
|
||||||
text = link.get_text().lower()
|
text = link.get_text().lower()
|
||||||
|
|
||||||
# Look for download buttons/links
|
# Look for download buttons/links
|
||||||
if any(keyword in text for keyword in ['télécharger', 'download', 'ddl', 'mega', 'google', 'drive']):
|
if any(
|
||||||
|
keyword in text
|
||||||
|
for keyword in [
|
||||||
|
"télécharger",
|
||||||
|
"download",
|
||||||
|
"ddl",
|
||||||
|
"mega",
|
||||||
|
"google",
|
||||||
|
"drive",
|
||||||
|
]
|
||||||
|
):
|
||||||
# Check if it's a direct link or to a file host
|
# Check if it's a direct link or to a file host
|
||||||
if any(host in href.lower() for host in ['mega.nz', 'drive.google.com', 'uptobox.com', '1fichier.com']):
|
if any(
|
||||||
|
host in href.lower()
|
||||||
|
for host in [
|
||||||
|
"mega.nz",
|
||||||
|
"drive.google.com",
|
||||||
|
"uptobox.com",
|
||||||
|
"1fichier.com",
|
||||||
|
]
|
||||||
|
):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return href, filename
|
return href, filename
|
||||||
|
|
||||||
# Method 2: Look for iframe with video player
|
# Method 2: Look for iframe with video player
|
||||||
iframes = soup.find_all('iframe')
|
iframes = soup.find_all("iframe")
|
||||||
for iframe in iframes:
|
for iframe in iframes:
|
||||||
src = iframe.get('src', '')
|
src = iframe.get("src", "")
|
||||||
if src and any(provider in src for provider in ['video', 'player', 'stream', 'play']):
|
if src and any(
|
||||||
if src.startswith('http'):
|
provider in src
|
||||||
|
for provider in ["video", "player", "stream", "play"]
|
||||||
|
):
|
||||||
|
if src.startswith("http"):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 3: Look for video tags
|
# Method 3: Look for video tags
|
||||||
videos = soup.find_all('video')
|
videos = soup.find_all("video")
|
||||||
for video in videos:
|
for video in videos:
|
||||||
src = video.get('src', '')
|
src = video.get("src", "")
|
||||||
if src:
|
if src:
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Check source tags
|
# Check source tags
|
||||||
sources = video.find_all('source')
|
sources = video.find_all("source")
|
||||||
for source in sources:
|
for source in sources:
|
||||||
src = source.get('src', '')
|
src = source.get("src", "")
|
||||||
if src:
|
if src:
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 4: Look in scripts for video URLs
|
# Method 4: Look in scripts for video URLs
|
||||||
scripts = soup.find_all('script')
|
scripts = soup.find_all("script")
|
||||||
for script in scripts:
|
for script in scripts:
|
||||||
if script.string:
|
if script.string:
|
||||||
# Look for common video patterns
|
# Look for common video patterns
|
||||||
@@ -91,26 +116,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
matches = re.findall(pattern, script.string)
|
matches = re.findall(pattern, script.string)
|
||||||
for match in matches:
|
for match in matches:
|
||||||
# Clean up escaped characters
|
# Clean up escaped characters
|
||||||
match = match.replace('\\/', '/').replace('\\', '')
|
match = match.replace("\\/", "/").replace("\\", "")
|
||||||
if any(ext in match for ext in ['mp4', 'm3u8', 'mkv']):
|
if any(ext in match for ext in ["mp4", "m3u8", "mkv"]):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return match, filename
|
return match, filename
|
||||||
|
|
||||||
# Look for anime-ultime specific patterns
|
# Look for anime-ultime specific patterns
|
||||||
# They sometimes store links in JavaScript variables
|
# They sometimes store links in JavaScript variables
|
||||||
ddl_match = re.search(r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string)
|
ddl_match = re.search(
|
||||||
|
r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string
|
||||||
|
)
|
||||||
if ddl_match:
|
if ddl_match:
|
||||||
ddl_url = ddl_match.group(1)
|
ddl_url = ddl_match.group(1)
|
||||||
if ddl_url.startswith('http'):
|
if ddl_url.startswith("http"):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return ddl_url, filename
|
return ddl_url, filename
|
||||||
|
|
||||||
# Method 5: Look for links with specific classes or IDs
|
# Method 5: Look for links with specific classes or IDs
|
||||||
# Anime-Ultime might use specific class names for download links
|
# Anime-Ultime might use specific class names for download links
|
||||||
potential_links = soup.find_all('a', class_=re.compile(r'download|ddl|episode', re.I))
|
potential_links = soup.find_all(
|
||||||
|
"a", class_=re.compile(r"download|ddl|episode", re.I)
|
||||||
|
)
|
||||||
for link in potential_links:
|
for link in potential_links:
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
if href and href.startswith('http'):
|
if href and href.startswith("http"):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return href, filename
|
return href, filename
|
||||||
|
|
||||||
@@ -132,36 +161,38 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
episode = "01"
|
episode = "01"
|
||||||
|
|
||||||
# Format: info-0-1/EPISODE_ID or info-0-1/EPISODE_ID/NAME-EP-vostfr
|
# Format: info-0-1/EPISODE_ID or info-0-1/EPISODE_ID/NAME-EP-vostfr
|
||||||
if 'info-0-1/' in url:
|
if "info-0-1/" in url:
|
||||||
# Extract episode ID
|
# Extract episode ID
|
||||||
ep_match = re.search(r'info-0-1/(\d+)', url)
|
ep_match = re.search(r"info-0-1/(\d+)", url)
|
||||||
if ep_match:
|
if ep_match:
|
||||||
ep_id = ep_match.group(1)
|
ep_id = ep_match.group(1)
|
||||||
|
|
||||||
# Try to get anime name from URL path
|
# Try to get anime name from URL path
|
||||||
name_match = re.search(r'info-0-1/\d+/([^/]+)', url)
|
name_match = re.search(r"info-0-1/\d+/([^/]+)", url)
|
||||||
if name_match:
|
if name_match:
|
||||||
raw_name = name_match.group(1)
|
raw_name = name_match.group(1)
|
||||||
# Extract episode number
|
# Extract episode number
|
||||||
ep_num_match = re.search(r'-(\d+)-vostfr$', raw_name, re.I)
|
ep_num_match = re.search(r"-(\d+)-vostfr$", raw_name, re.I)
|
||||||
if ep_num_match:
|
if ep_num_match:
|
||||||
episode = ep_num_match.group(1).zfill(2)
|
episode = ep_num_match.group(1).zfill(2)
|
||||||
# Remove episode number and suffix from name
|
# Remove episode number and suffix from name
|
||||||
anime_name = re.sub(r'-\d+-vostfr$', '', raw_name, flags=re.I).replace('-', ' ')
|
anime_name = re.sub(
|
||||||
|
r"-\d+-vostfr$", "", raw_name, flags=re.I
|
||||||
|
).replace("-", " ")
|
||||||
else:
|
else:
|
||||||
# Just use the ID
|
# Just use the ID
|
||||||
anime_name = f"Episode {ep_id}"
|
anime_name = f"Episode {ep_id}"
|
||||||
else:
|
else:
|
||||||
anime_name = f"Episode {ep_id}"
|
anime_name = f"Episode {ep_id}"
|
||||||
|
|
||||||
elif 'file-0-1/' in url:
|
elif "file-0-1/" in url:
|
||||||
# Extract from file-0-1/ID-NAME format
|
# Extract from file-0-1/ID-NAME format
|
||||||
file_match = re.search(r'file-0-1/\d+-(.+)$', url)
|
file_match = re.search(r"file-0-1/\d+-(.+)$", url)
|
||||||
if file_match:
|
if file_match:
|
||||||
anime_name = file_match.group(1).replace('-', ' ')
|
anime_name = file_match.group(1).replace("-", " ")
|
||||||
|
|
||||||
# Sanitize filename
|
# Sanitize filename
|
||||||
anime_name = anime_name.replace('/', ' ').strip()
|
anime_name = anime_name.replace("/", " ").strip()
|
||||||
filename = f"{anime_name} - Episode {episode}.mp4"
|
filename = f"{anime_name} - Episode {episode}.mp4"
|
||||||
return filename.title()
|
return filename.title()
|
||||||
|
|
||||||
@@ -173,30 +204,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
try:
|
try:
|
||||||
print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}")
|
print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}")
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
'synopsis': None,
|
"synopsis": None,
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'rating': None,
|
"rating": None,
|
||||||
'release_year': None,
|
"release_year": None,
|
||||||
'studio': None,
|
"studio": None,
|
||||||
'poster_image': None,
|
"poster_image": None,
|
||||||
'banner_image': None,
|
"banner_image": None,
|
||||||
'total_episodes': None,
|
"total_episodes": None,
|
||||||
'status': None,
|
"status": None,
|
||||||
'alternative_titles': []
|
"alternative_titles": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract synopsis
|
# Extract synopsis
|
||||||
synopsis_selectors = [
|
synopsis_selectors = [
|
||||||
'div.synopsis',
|
"div.synopsis",
|
||||||
'div.description',
|
"div.description",
|
||||||
'div[class*="synopsis"]',
|
'div[class*="synopsis"]',
|
||||||
'div[class*="synopsis"]',
|
'div[class*="synopsis"]',
|
||||||
'p.synopsis',
|
"p.synopsis",
|
||||||
'.info',
|
".info",
|
||||||
'div.texte'
|
"div.texte",
|
||||||
]
|
]
|
||||||
|
|
||||||
for selector in synopsis_selectors:
|
for selector in synopsis_selectors:
|
||||||
@@ -204,68 +235,73 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
if synopsis_elem:
|
if synopsis_elem:
|
||||||
synopsis = synopsis_elem.get_text(strip=True)
|
synopsis = synopsis_elem.get_text(strip=True)
|
||||||
if len(synopsis) > 50:
|
if len(synopsis) > 50:
|
||||||
metadata['synopsis'] = synopsis
|
metadata["synopsis"] = synopsis
|
||||||
break
|
break
|
||||||
|
|
||||||
# Extract genres from meta tags and page content
|
# Extract genres from meta tags and page content
|
||||||
page_text = soup.get_text()
|
page_text = soup.get_text()
|
||||||
|
|
||||||
# Look for genre in meta tags
|
# Look for genre in meta tags
|
||||||
genre_meta = soup.find('meta', property='genre') or soup.find('meta', attrs={'name': 'genre'})
|
genre_meta = soup.find("meta", property="genre") or soup.find(
|
||||||
|
"meta", attrs={"name": "genre"}
|
||||||
|
)
|
||||||
if genre_meta:
|
if genre_meta:
|
||||||
genres_text = genre_meta.get('content', '')
|
genres_text = genre_meta.get("content", "")
|
||||||
if genres_text:
|
if genres_text:
|
||||||
metadata['genres'] = [g.strip() for g in genres_text.split(',')]
|
metadata["genres"] = [g.strip() for g in genres_text.split(",")]
|
||||||
|
|
||||||
# Try to find genre links
|
# Try to find genre links
|
||||||
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type|cat', re.I))
|
genre_links = soup.find_all(
|
||||||
|
"a", href=re.compile(r"genre|tag|type|cat", re.I)
|
||||||
|
)
|
||||||
if genre_links:
|
if genre_links:
|
||||||
for link in genre_links[:5]:
|
for link in genre_links[:5]:
|
||||||
genre = link.get_text(strip=True)
|
genre = link.get_text(strip=True)
|
||||||
if genre and genre not in metadata['genres']:
|
if genre and genre not in metadata["genres"]:
|
||||||
metadata['genres'].append(genre)
|
metadata["genres"].append(genre)
|
||||||
|
|
||||||
# Extract rating
|
# Extract rating
|
||||||
rating_selectors = [
|
rating_selectors = [
|
||||||
'span.rating',
|
"span.rating",
|
||||||
'div.rating',
|
"div.rating",
|
||||||
'span.score',
|
"span.score",
|
||||||
'div.note',
|
"div.note",
|
||||||
'.rating'
|
".rating",
|
||||||
]
|
]
|
||||||
|
|
||||||
for selector in rating_selectors:
|
for selector in rating_selectors:
|
||||||
rating_elem = soup.select_one(selector)
|
rating_elem = soup.select_one(selector)
|
||||||
if rating_elem:
|
if rating_elem:
|
||||||
rating_text = rating_elem.get_text(strip=True)
|
rating_text = rating_elem.get_text(strip=True)
|
||||||
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
|
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
|
||||||
if rating_match:
|
if rating_match:
|
||||||
metadata['rating'] = f"{rating_match.group(1)}/10"
|
metadata["rating"] = f"{rating_match.group(1)}/10"
|
||||||
break
|
break
|
||||||
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*5', rating_text)
|
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*5", rating_text)
|
||||||
if rating_match:
|
if rating_match:
|
||||||
rating_val = float(rating_match.group(1)) * 2
|
rating_val = float(rating_match.group(1)) * 2
|
||||||
metadata['rating'] = f"{rating_val:.1f}/10"
|
metadata["rating"] = f"{rating_val:.1f}/10"
|
||||||
break
|
break
|
||||||
|
|
||||||
# Extract release year
|
# Extract release year
|
||||||
year_match = re.search(r'\b(19\d{2}|20\d{2})\b', page_text)
|
year_match = re.search(r"\b(19\d{2}|20\d{2})\b", page_text)
|
||||||
if year_match:
|
if year_match:
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
current_year = datetime.datetime.now().year + 2
|
current_year = datetime.datetime.now().year + 2
|
||||||
year = int(year_match.group(1))
|
year = int(year_match.group(1))
|
||||||
if 1950 <= year <= current_year:
|
if 1950 <= year <= current_year:
|
||||||
metadata['release_year'] = year
|
metadata["release_year"] = year
|
||||||
|
|
||||||
# Extract poster image from og:image
|
# Extract poster image from og:image
|
||||||
og_image = soup.find('meta', property='og:image')
|
og_image = soup.find("meta", property="og:image")
|
||||||
if og_image:
|
if og_image:
|
||||||
metadata['poster_image'] = og_image.get('content')
|
metadata["poster_image"] = og_image.get("content")
|
||||||
|
|
||||||
# Extract total episodes
|
# Extract total episodes
|
||||||
episodes_count = len(await self.get_episodes(anime_url))
|
episodes_count = len(await self.get_episodes(anime_url))
|
||||||
if episodes_count > 0:
|
if episodes_count > 0:
|
||||||
metadata['total_episodes'] = episodes_count
|
metadata["total_episodes"] = episodes_count
|
||||||
|
|
||||||
print(f"[ANIME-ULTIME] Extracted metadata: {metadata}")
|
print(f"[ANIME-ULTIME] Extracted metadata: {metadata}")
|
||||||
return metadata
|
return metadata
|
||||||
@@ -274,7 +310,9 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
print(f"[ANIME-ULTIME] Error extracting metadata: {e}")
|
print(f"[ANIME-ULTIME] Error extracting metadata: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
async def search_anime(
|
||||||
|
self, query: str, lang: str = "vostfr", include_metadata: bool = False
|
||||||
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Search for anime on anime-ultime
|
Search for anime on anime-ultime
|
||||||
Returns list of anime with title, url, and cover image
|
Returns list of anime with title, url, and cover image
|
||||||
@@ -286,27 +324,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import time
|
import time
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...")
|
print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...")
|
||||||
|
|
||||||
# Anime-Ultime uses POST for search
|
# Anime-Ultime uses POST for search
|
||||||
search_url = "https://www.anime-ultime.net/search-0-1"
|
search_url = "https://www.anime-ultime.net/search-0-1"
|
||||||
|
|
||||||
response = await self.client.post(search_url, data={'search': query})
|
response = await self.client.post(search_url, data={"search": query})
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
print(f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s")
|
print(
|
||||||
|
f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s"
|
||||||
|
)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Look for search result links - better parsing
|
# Look for search result links - better parsing
|
||||||
# Search results use file-0-1/ pattern, not info-
|
# Search results use file-0-1/ pattern, not info-
|
||||||
search_results = soup.find_all('a', href=re.compile(r'file-0-1/'))
|
search_results = soup.find_all("a", href=re.compile(r"file-0-1/"))
|
||||||
|
|
||||||
seen_urls = set()
|
seen_urls = set()
|
||||||
for result in search_results[:10]: # Limit to 10 results
|
for result in search_results[:10]: # Limit to 10 results
|
||||||
href = result.get('href', '')
|
href = result.get("href", "")
|
||||||
raw_title = result.get_text().strip()
|
raw_title = result.get_text().strip()
|
||||||
|
|
||||||
# Skip if no href
|
# Skip if no href
|
||||||
@@ -322,40 +363,44 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
better_title = raw_title
|
better_title = raw_title
|
||||||
|
|
||||||
# If raw_title is just "Télécharger" or similar, try to find better title
|
# If raw_title is just "Télécharger" or similar, try to find better title
|
||||||
if len(raw_title) < 5 or raw_title.lower() in ['télécharger', 'download', 'ddl']:
|
if len(raw_title) < 5 or raw_title.lower() in [
|
||||||
|
"télécharger",
|
||||||
|
"download",
|
||||||
|
"ddl",
|
||||||
|
]:
|
||||||
# Try to extract from URL (file-0-1/ID-Title format)
|
# Try to extract from URL (file-0-1/ID-Title format)
|
||||||
url_match = re.search(r'file-0-1/\d+-(.+)$', href)
|
url_match = re.search(r"file-0-1/\d+-(.+)$", href)
|
||||||
if url_match:
|
if url_match:
|
||||||
better_title = url_match.group(1).replace('-', ' ').title()
|
better_title = url_match.group(1).replace("-", " ").title()
|
||||||
|
|
||||||
# If still no good title, look at parent/row elements
|
# If still no good title, look at parent/row elements
|
||||||
if len(better_title) < 5:
|
if len(better_title) < 5:
|
||||||
# Check parent row (table structure)
|
# Check parent row (table structure)
|
||||||
row = result.find_parent(['tr', 'td', 'div'])
|
row = result.find_parent(["tr", "td", "div"])
|
||||||
if row:
|
if row:
|
||||||
# Look for text in the row that's not the link text
|
# Look for text in the row that's not the link text
|
||||||
row_text = row.get_text().strip()
|
row_text = row.get_text().strip()
|
||||||
# Remove the link text from row text
|
# Remove the link text from row text
|
||||||
if raw_title in row_text:
|
if raw_title in row_text:
|
||||||
row_text = row_text.replace(raw_title, '').strip()
|
row_text = row_text.replace(raw_title, "").strip()
|
||||||
if len(row_text) > 5 and len(row_text) < 100:
|
if len(row_text) > 5 and len(row_text) < 100:
|
||||||
better_title = row_text
|
better_title = row_text
|
||||||
|
|
||||||
# Make URL absolute
|
# Make URL absolute
|
||||||
if not href.startswith('http'):
|
if not href.startswith("http"):
|
||||||
href = urljoin("https://www.anime-ultime.net/", href)
|
href = urljoin("https://www.anime-ultime.net/", href)
|
||||||
|
|
||||||
result_item = {
|
result_item = {
|
||||||
'title': better_title,
|
"title": better_title,
|
||||||
'url': href,
|
"url": href,
|
||||||
'type': 'search_result',
|
"type": "search_result",
|
||||||
'metadata': None
|
"metadata": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fetch metadata if requested
|
# Fetch metadata if requested
|
||||||
if include_metadata:
|
if include_metadata:
|
||||||
metadata = await self.get_anime_metadata(href)
|
metadata = await self.get_anime_metadata(href)
|
||||||
result_item['metadata'] = metadata
|
result_item["metadata"] = metadata
|
||||||
|
|
||||||
results.append(result_item)
|
results.append(result_item)
|
||||||
|
|
||||||
@@ -373,27 +418,27 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
episodes = []
|
episodes = []
|
||||||
|
|
||||||
# Look for episode links - anime-ultime uses info-XXXXX-Name-XX-vostfr format
|
# Look for episode links - anime-ultime uses info-XXXXX-Name-XX-vostfr format
|
||||||
# The URL pattern is info-0-1/ID-Anime-Name-XX-vostfr where XX is episode number
|
# The URL pattern is info-0-1/ID-Anime-Name-XX-vostfr where XX is episode number
|
||||||
episode_links = soup.find_all('a', href=re.compile(r'info-0-1/\d+'))
|
episode_links = soup.find_all("a", href=re.compile(r"info-0-1/\d+"))
|
||||||
|
|
||||||
for link in episode_links:
|
for link in episode_links:
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
text = link.get_text().strip()
|
text = link.get_text().strip()
|
||||||
|
|
||||||
# Extract episode number from URL pattern
|
# Extract episode number from URL pattern
|
||||||
# Matches: info-0-1/30200/Naruto-OAV-01-vostfr
|
# Matches: info-0-1/30200/Naruto-OAV-01-vostfr
|
||||||
match = re.search(r'-(\d+)-vostfr$', href, re.I)
|
match = re.search(r"-(\d+)-vostfr$", href, re.I)
|
||||||
if not match:
|
if not match:
|
||||||
# Try other patterns
|
# Try other patterns
|
||||||
match = re.search(r'Episode[-\s]?(\d+)', href, re.I)
|
match = re.search(r"Episode[-\s]?(\d+)", href, re.I)
|
||||||
if not match:
|
if not match:
|
||||||
# Try to extract from text
|
# Try to extract from text
|
||||||
match = re.search(r'(\d+)', text)
|
match = re.search(r"(\d+)", text)
|
||||||
|
|
||||||
if match:
|
if match:
|
||||||
episode_num = match.group(1).zfill(2) # Pad with zero
|
episode_num = match.group(1).zfill(2) # Pad with zero
|
||||||
@@ -401,32 +446,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
# Extract the episode ID from href and build correct URL
|
# Extract the episode ID from href and build correct URL
|
||||||
# href might be "info-0-1/30200" or "info-0-1/30200/..."
|
# href might be "info-0-1/30200" or "info-0-1/30200/..."
|
||||||
# We need: https://www.anime-ultime.net/info-0-1/30200
|
# We need: https://www.anime-ultime.net/info-0-1/30200
|
||||||
ep_id_match = re.search(r'info-0-1/(\d+)', href)
|
ep_id_match = re.search(r"info-0-1/(\d+)", href)
|
||||||
if ep_id_match:
|
if ep_id_match:
|
||||||
ep_id = ep_id_match.group(1)
|
ep_id = ep_id_match.group(1)
|
||||||
# Build the correct episode URL
|
# Build the correct episode URL
|
||||||
episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}"
|
episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}"
|
||||||
else:
|
else:
|
||||||
# Fallback to making URL absolute
|
# Fallback to making URL absolute
|
||||||
if not href.startswith('http'):
|
if not href.startswith("http"):
|
||||||
href = urljoin(anime_url, href)
|
href = urljoin(anime_url, href)
|
||||||
episode_url = href
|
episode_url = href
|
||||||
|
|
||||||
episodes.append({
|
episodes.append(
|
||||||
'episode': episode_num,
|
{"episode": episode_num, "url": episode_url, "title": text}
|
||||||
'url': episode_url,
|
)
|
||||||
'title': text
|
|
||||||
})
|
|
||||||
|
|
||||||
# Remove duplicates and sort
|
# Remove duplicates and sort
|
||||||
seen = set()
|
seen = set()
|
||||||
unique_episodes = []
|
unique_episodes = []
|
||||||
for ep in episodes:
|
for ep in episodes:
|
||||||
if ep['episode'] not in seen:
|
if ep["episode"] not in seen:
|
||||||
seen.add(ep['episode'])
|
seen.add(ep["episode"])
|
||||||
unique_episodes.append(ep)
|
unique_episodes.append(ep)
|
||||||
|
|
||||||
unique_episodes.sort(key=lambda x: int(x['episode']))
|
unique_episodes.sort(key=lambda x: int(x["episode"]))
|
||||||
|
|
||||||
return unique_episodes
|
return unique_episodes
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""French-Manga.net anime streaming site downloader"""
|
"""French-Manga.net anime streaming site downloader"""
|
||||||
|
|
||||||
from .base import BaseAnimeSite
|
from .base import BaseAnimeSite
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
import re
|
import re
|
||||||
@@ -17,11 +18,12 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
"french-manga.net",
|
"french-manga.net",
|
||||||
"w16.french-manga.net",
|
"w16.french-manga.net",
|
||||||
"w15.french-manga.net",
|
"w15.french-manga.net",
|
||||||
"www.french-manga.net"
|
"www.french-manga.net",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.id = "french-manga"
|
||||||
self.base_url = "https://w16.french-manga.net"
|
self.base_url = "https://w16.french-manga.net"
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
@@ -29,9 +31,7 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||||
|
|
||||||
async def search_anime(
|
async def search_anime(
|
||||||
self,
|
self, query: str, lang: str = "vostfr"
|
||||||
query: str,
|
|
||||||
lang: str = "vostfr"
|
|
||||||
) -> List[Dict[str, str]]:
|
) -> List[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Search for anime on French-Manga.
|
Search for anime on French-Manga.
|
||||||
@@ -47,46 +47,50 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
# French-Manga uses a search endpoint
|
# French-Manga uses a search endpoint
|
||||||
search_url = f"{self.base_url}/index.php?do=search"
|
search_url = f"{self.base_url}/index.php?do=search"
|
||||||
params = {
|
params = {
|
||||||
'do': 'search',
|
"do": "search",
|
||||||
'subaction': 'search',
|
"subaction": "search",
|
||||||
'story': query,
|
"story": query,
|
||||||
'x': '0',
|
"x": "0",
|
||||||
'y': '0'
|
"y": "0",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = await self.client.post(search_url, data=params)
|
response = await self.client.post(search_url, data=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Look for search results in article or story classes
|
# Look for search results in article or story classes
|
||||||
for item in soup.find_all('article', class_=lambda x: x and 'story' in x.lower()):
|
for item in soup.find_all(
|
||||||
title_elem = item.find(['h2', 'h3', 'h4'])
|
"article", class_=lambda x: x and "story" in x.lower()
|
||||||
link_elem = item.find('a', href=True)
|
):
|
||||||
img_elem = item.find('img')
|
title_elem = item.find(["h2", "h3", "h4"])
|
||||||
|
link_elem = item.find("a", href=True)
|
||||||
|
img_elem = item.find("img")
|
||||||
|
|
||||||
if title_elem and link_elem:
|
if title_elem and link_elem:
|
||||||
title = title_elem.get_text(strip=True)
|
title = title_elem.get_text(strip=True)
|
||||||
url = link_elem['href']
|
url = link_elem["href"]
|
||||||
|
|
||||||
# Ensure absolute URL
|
# Ensure absolute URL
|
||||||
if url.startswith('/'):
|
if url.startswith("/"):
|
||||||
url = self.base_url + url
|
url = self.base_url + url
|
||||||
|
|
||||||
cover_image = ""
|
cover_image = ""
|
||||||
if img_elem and img_elem.get('src'):
|
if img_elem and img_elem.get("src"):
|
||||||
cover_image = img_elem['src']
|
cover_image = img_elem["src"]
|
||||||
if cover_image.startswith('/'):
|
if cover_image.startswith("/"):
|
||||||
cover_image = self.base_url + cover_image
|
cover_image = self.base_url + cover_image
|
||||||
|
|
||||||
results.append({
|
results.append(
|
||||||
'title': title,
|
{
|
||||||
'url': url,
|
"title": title,
|
||||||
'cover_image': cover_image,
|
"url": url,
|
||||||
'lang': lang
|
"cover_image": cover_image,
|
||||||
})
|
"lang": lang,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Found {len(results)} anime results for query: {query}")
|
logger.info(f"Found {len(results)} anime results for query: {query}")
|
||||||
return results
|
return results
|
||||||
@@ -96,9 +100,7 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_episodes(
|
async def get_episodes(
|
||||||
self,
|
self, anime_url: str, lang: str = "vostfr"
|
||||||
anime_url: str,
|
|
||||||
lang: str = "vostfr"
|
|
||||||
) -> List[Dict[str, str]]:
|
) -> List[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Get episode list for an anime.
|
Get episode list for an anime.
|
||||||
@@ -115,34 +117,36 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
episodes = []
|
episodes = []
|
||||||
|
|
||||||
# Look for episode links (typically in a list or table)
|
# Look for episode links (typically in a list or table)
|
||||||
# French-Manga usually has episode links in <a> tags with episode numbers
|
# French-Manga usually has episode links in <a> tags with episode numbers
|
||||||
for link in soup.find_all('a', href=True):
|
for link in soup.find_all("a", href=True):
|
||||||
href = link['href']
|
href = link["href"]
|
||||||
text = link.get_text(strip=True)
|
text = link.get_text(strip=True)
|
||||||
|
|
||||||
# Pattern: Episode links usually contain "episode" or numbers
|
# Pattern: Episode links usually contain "episode" or numbers
|
||||||
if re.search(r'episode?\s*\d+', text.lower()):
|
if re.search(r"episode?\s*\d+", text.lower()):
|
||||||
episode_num = re.search(r'(\d+)', text)
|
episode_num = re.search(r"(\d+)", text)
|
||||||
if episode_num:
|
if episode_num:
|
||||||
episode_number = int(episode_num.group(1))
|
episode_number = int(episode_num.group(1))
|
||||||
|
|
||||||
# Ensure absolute URL
|
# Ensure absolute URL
|
||||||
if href.startswith('/'):
|
if href.startswith("/"):
|
||||||
href = self.base_url + href
|
href = self.base_url + href
|
||||||
|
|
||||||
episodes.append({
|
episodes.append(
|
||||||
'episode_number': episode_number,
|
{
|
||||||
'url': href,
|
"episode_number": episode_number,
|
||||||
'title': text,
|
"url": href,
|
||||||
'host': 'french-manga'
|
"title": text,
|
||||||
})
|
"host": "french-manga",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Sort by episode number
|
# Sort by episode number
|
||||||
episodes.sort(key=lambda x: x['episode_number'])
|
episodes.sort(key=lambda x: x["episode_number"])
|
||||||
|
|
||||||
logger.info(f"Found {len(episodes)} episodes for {anime_url}")
|
logger.info(f"Found {len(episodes)} episodes for {anime_url}")
|
||||||
return episodes
|
return episodes
|
||||||
@@ -166,31 +170,33 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
# Extract title
|
# Extract title
|
||||||
title = ""
|
title = ""
|
||||||
title_elem = soup.find('h1') or soup.find('h2', class_='title')
|
title_elem = soup.find("h1") or soup.find("h2", class_="title")
|
||||||
if title_elem:
|
if title_elem:
|
||||||
title = title_elem.get_text(strip=True)
|
title = title_elem.get_text(strip=True)
|
||||||
|
|
||||||
# Extract synopsis
|
# Extract synopsis
|
||||||
synopsis = ""
|
synopsis = ""
|
||||||
synopsis_elem = soup.find('div', class_=lambda x: x and 'story' in x.lower())
|
synopsis_elem = soup.find(
|
||||||
|
"div", class_=lambda x: x and "story" in x.lower()
|
||||||
|
)
|
||||||
if synopsis_elem:
|
if synopsis_elem:
|
||||||
synopsis = synopsis_elem.get_text(strip=True)
|
synopsis = synopsis_elem.get_text(strip=True)
|
||||||
|
|
||||||
# Extract cover image
|
# Extract cover image
|
||||||
poster_image = ""
|
poster_image = ""
|
||||||
img_elem = soup.find('img', class_=lambda x: x and 'poster' in x.lower())
|
img_elem = soup.find("img", class_=lambda x: x and "poster" in x.lower())
|
||||||
if img_elem and img_elem.get('src'):
|
if img_elem and img_elem.get("src"):
|
||||||
poster_image = img_elem['src']
|
poster_image = img_elem["src"]
|
||||||
if poster_image.startswith('/'):
|
if poster_image.startswith("/"):
|
||||||
poster_image = self.base_url + poster_image
|
poster_image = self.base_url + poster_image
|
||||||
|
|
||||||
# Extract genres
|
# Extract genres
|
||||||
genres = []
|
genres = []
|
||||||
genre_links = soup.find_all('a', href=re.compile(r'/xfsearch/.*genre/'))
|
genre_links = soup.find_all("a", href=re.compile(r"/xfsearch/.*genre/"))
|
||||||
for link in genre_links[:10]: # Limit to 10 genres
|
for link in genre_links[:10]: # Limit to 10 genres
|
||||||
genre = link.get_text(strip=True)
|
genre = link.get_text(strip=True)
|
||||||
if genre:
|
if genre:
|
||||||
@@ -198,36 +204,38 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
|
|
||||||
# Extract rating (if available)
|
# Extract rating (if available)
|
||||||
rating = ""
|
rating = ""
|
||||||
rating_elem = soup.find(['span', 'div'], class_=lambda x: x and 'rating' in x.lower())
|
rating_elem = soup.find(
|
||||||
|
["span", "div"], class_=lambda x: x and "rating" in x.lower()
|
||||||
|
)
|
||||||
if rating_elem:
|
if rating_elem:
|
||||||
rating = rating_elem.get_text(strip=True)
|
rating = rating_elem.get_text(strip=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'title': title,
|
"title": title,
|
||||||
'synopsis': synopsis,
|
"synopsis": synopsis,
|
||||||
'genres': genres,
|
"genres": genres,
|
||||||
'rating': rating,
|
"rating": rating,
|
||||||
'release_year': '',
|
"release_year": "",
|
||||||
'studio': '',
|
"studio": "",
|
||||||
'poster_image': poster_image,
|
"poster_image": poster_image,
|
||||||
'total_episodes': len(await self.get_episodes(anime_url)),
|
"total_episodes": len(await self.get_episodes(anime_url)),
|
||||||
'status': '',
|
"status": "",
|
||||||
'languages': ['vf', 'vostfr']
|
"languages": ["vf", "vostfr"],
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting anime metadata: {e}")
|
logger.error(f"Error getting anime metadata: {e}")
|
||||||
return {
|
return {
|
||||||
'title': '',
|
"title": "",
|
||||||
'synopsis': '',
|
"synopsis": "",
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'rating': '',
|
"rating": "",
|
||||||
'release_year': '',
|
"release_year": "",
|
||||||
'studio': '',
|
"studio": "",
|
||||||
'poster_image': '',
|
"poster_image": "",
|
||||||
'total_episodes': 0,
|
"total_episodes": 0,
|
||||||
'status': '',
|
"status": "",
|
||||||
'languages': ['vf', 'vostfr']
|
"languages": ["vf", "vostfr"],
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_download_link(self, url: str) -> tuple[str, str]:
|
async def get_download_link(self, url: str) -> tuple[str, str]:
|
||||||
@@ -248,20 +256,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
# Look for iframe or video player
|
# Look for iframe or video player
|
||||||
iframe = soup.find('iframe', src=True)
|
iframe = soup.find("iframe", src=True)
|
||||||
if iframe:
|
if iframe:
|
||||||
video_url = iframe['src']
|
video_url = iframe["src"]
|
||||||
else:
|
else:
|
||||||
# Look for video tag directly
|
# Look for video tag directly
|
||||||
video = soup.find('video', src=True)
|
video = soup.find("video", src=True)
|
||||||
if video:
|
if video:
|
||||||
video_url = video['src']
|
video_url = video["src"]
|
||||||
else:
|
else:
|
||||||
# Try to find in script tags
|
# Try to find in script tags
|
||||||
scripts = soup.find_all('script')
|
scripts = soup.find_all("script")
|
||||||
for script in scripts:
|
for script in scripts:
|
||||||
if script.string:
|
if script.string:
|
||||||
# Look for iframe or video URLs in JavaScript
|
# Look for iframe or video URLs in JavaScript
|
||||||
@@ -274,20 +282,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
if match:
|
if match:
|
||||||
video_url = match.group(1)
|
video_url = match.group(1)
|
||||||
break
|
break
|
||||||
if 'video_url' in locals():
|
if "video_url" in locals():
|
||||||
break
|
break
|
||||||
|
|
||||||
if 'video_url' not in locals():
|
if "video_url" not in locals():
|
||||||
raise ValueError("Could not find video player URL")
|
raise ValueError("Could not find video player URL")
|
||||||
|
|
||||||
# Ensure absolute URL
|
# Ensure absolute URL
|
||||||
if video_url.startswith('//'):
|
if video_url.startswith("//"):
|
||||||
video_url = 'https:' + video_url
|
video_url = "https:" + video_url
|
||||||
elif video_url.startswith('/'):
|
elif video_url.startswith("/"):
|
||||||
video_url = self.base_url + video_url
|
video_url = self.base_url + video_url
|
||||||
|
|
||||||
# Extract episode title
|
# Extract episode title
|
||||||
title_elem = soup.find('h1') or soup.find('h2')
|
title_elem = soup.find("h1") or soup.find("h2")
|
||||||
episode_title = title_elem.get_text(strip=True) if title_elem else "Episode"
|
episode_title = title_elem.get_text(strip=True) if title_elem else "Episode"
|
||||||
episode_title = sanitize_filename(episode_title)
|
episode_title = sanitize_filename(episode_title)
|
||||||
|
|
||||||
|
|||||||
@@ -7,79 +7,100 @@ from urllib.parse import urljoin
|
|||||||
|
|
||||||
class NekoSamaDownloader(BaseAnimeSite):
|
class NekoSamaDownloader(BaseAnimeSite):
|
||||||
"""Downloader for neko-sama.org (anime streaming via Gupy)
|
"""Downloader for neko-sama.org (anime streaming via Gupy)
|
||||||
|
|
||||||
NOTE: neko-sama.org now redirects to Gupy, which is a legal streaming search engine.
|
NOTE: neko-sama.org now redirects to Gupy, which is a legal streaming search engine.
|
||||||
It does NOT host video content - it provides metadata about where to watch legally.
|
It does NOT host video content - it provides metadata about where to watch legally.
|
||||||
This provider can search and get metadata but cannot provide direct download links.
|
This provider can search and get metadata but cannot provide direct download links.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BASE_DOMAINS = ["neko-sama.org", "www.neko-sama.org", "neko-sama.fr", "nekosama.fr", "www.gupy.fr", "gupy.fr"]
|
BASE_DOMAINS = [
|
||||||
|
"neko-sama.org",
|
||||||
|
"www.neko-sama.org",
|
||||||
|
"neko-sama.fr",
|
||||||
|
"nekosama.fr",
|
||||||
|
"www.gupy.fr",
|
||||||
|
"gupy.fr",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.id = "neko-sama"
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||||
|
|
||||||
async def get_download_link(self, url: str, target_filename: Optional[str] = None) -> tuple[str, str]:
|
async def get_download_link(
|
||||||
|
self, url: str, target_filename: Optional[str] = None
|
||||||
|
) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Extract download link from neko-sama URL.
|
Extract download link from neko-sama URL.
|
||||||
|
|
||||||
NOTE: neko-sama.org/Gupy is a legal streaming search engine, NOT a video host.
|
NOTE: neko-sama.org/Gupy is a legal streaming search engine, NOT a video host.
|
||||||
This returns streaming platform information instead of direct video links.
|
This returns streaming platform information instead of direct video links.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if this is a Gupy URL
|
# Check if this is a Gupy URL
|
||||||
if 'gupy.fr' in url or 'neko-sama.org' in url:
|
if "gupy.fr" in url or "neko-sama.org" in url:
|
||||||
response = await self.client.get(url, follow_redirects=True)
|
response = await self.client.get(url, follow_redirects=True)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
# Look for streaming platform links
|
# Look for streaming platform links
|
||||||
streaming_links = []
|
streaming_links = []
|
||||||
for link in soup.find_all('a', href=True):
|
for link in soup.find_all("a", href=True):
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
if '/out/' in href:
|
if "/out/" in href:
|
||||||
text = link.get_text(strip=True)
|
text = link.get_text(strip=True)
|
||||||
if text and 'Regarder' in text:
|
if text and "Regarder" in text:
|
||||||
streaming_links.append(f"{text}: {href}")
|
streaming_links.append(f"{text}: {href}")
|
||||||
|
|
||||||
if streaming_links:
|
if streaming_links:
|
||||||
title_elem = soup.find('h1') or soup.find('title')
|
title_elem = soup.find("h1") or soup.find("title")
|
||||||
title = title_elem.get_text(strip=True).split('|')[0].strip() if title_elem else "Unknown"
|
title = (
|
||||||
info = "Available streaming platforms:\n" + "\n".join(streaming_links[:5])
|
title_elem.get_text(strip=True).split("|")[0].strip()
|
||||||
|
if title_elem
|
||||||
|
else "Unknown"
|
||||||
|
)
|
||||||
|
info = "Available streaming platforms:\n" + "\n".join(
|
||||||
|
streaming_links[:5]
|
||||||
|
)
|
||||||
filename = target_filename or f"{title}_streaming_info.txt"
|
filename = target_filename or f"{title}_streaming_info.txt"
|
||||||
return info, filename
|
return info, filename
|
||||||
|
|
||||||
raise Exception("No streaming links found - Gupy is a legal streaming search, not a video host")
|
raise Exception(
|
||||||
|
"No streaming links found - Gupy is a legal streaming search, not a video host"
|
||||||
|
)
|
||||||
|
|
||||||
# Legacy: try original method for other URLs
|
# Legacy: try original method for other URLs
|
||||||
response = await self.client.get(url, follow_redirects=True)
|
response = await self.client.get(url, follow_redirects=True)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
# Method 1: Look for iframes with video
|
# Method 1: Look for iframes with video
|
||||||
iframes = soup.find_all('iframe')
|
iframes = soup.find_all("iframe")
|
||||||
for iframe in iframes:
|
for iframe in iframes:
|
||||||
src = iframe.get('src', '')
|
src = iframe.get("src", "")
|
||||||
if src and any(p in src for p in ['video', 'player', 'stream']):
|
if src and any(p in src for p in ["video", "player", "stream"]):
|
||||||
if not src.startswith('http'):
|
if not src.startswith("http"):
|
||||||
src = urljoin(str(response.url), src)
|
src = urljoin(str(response.url), src)
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 2: Look for video tags
|
# Method 2: Look for video tags
|
||||||
videos = soup.find_all('video')
|
videos = soup.find_all("video")
|
||||||
for video in videos:
|
for video in videos:
|
||||||
src = video.get('src') or video.get('data-src')
|
src = video.get("src") or video.get("data-src")
|
||||||
if src:
|
if src:
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
sources = video.find_all('source')
|
sources = video.find_all("source")
|
||||||
for source in sources:
|
for source in sources:
|
||||||
src = source.get('src', '')
|
src = source.get("src", "")
|
||||||
if src:
|
if src:
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 3: Look in scripts
|
# Method 3: Look in scripts
|
||||||
scripts = soup.find_all('script')
|
scripts = soup.find_all("script")
|
||||||
for script in scripts:
|
for script in scripts:
|
||||||
if script.string:
|
if script.string:
|
||||||
patterns = [
|
patterns = [
|
||||||
@@ -90,24 +111,26 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
matches = re.findall(pattern, script.string)
|
matches = re.findall(pattern, script.string)
|
||||||
for match in matches:
|
for match in matches:
|
||||||
match = match.replace('\\/', '/')
|
match = match.replace("\\/", "/")
|
||||||
if any(ext in match for ext in ['mp4', 'm3u8']):
|
if any(ext in match for ext in ["mp4", "m3u8"]):
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return match, filename
|
return match, filename
|
||||||
|
|
||||||
raise Exception("Could not find video link - Neko-Sama/Gupy does not host video content")
|
raise Exception(
|
||||||
|
"Could not find video link - Neko-Sama/Gupy does not host video content"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error extracting NekoSama link: {str(e)}")
|
raise Exception(f"Error extracting NekoSama link: {str(e)}")
|
||||||
|
|
||||||
def _generate_filename(self, url: str) -> str:
|
def _generate_filename(self, url: str) -> str:
|
||||||
parts = url.split('/')
|
parts = url.split("/")
|
||||||
anime_name = "anime"
|
anime_name = "anime"
|
||||||
episode = "1"
|
episode = "1"
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
if 'episode' in part.lower():
|
if "episode" in part.lower():
|
||||||
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
|
match = re.search(r"episode[-\s]*(\d+)", part, re.I)
|
||||||
if match:
|
if match:
|
||||||
episode = match.group(1)
|
episode = match.group(1)
|
||||||
|
|
||||||
@@ -118,31 +141,31 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
"""Get list of episodes for an anime."""
|
"""Get list of episodes for an anime."""
|
||||||
try:
|
try:
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
episodes = []
|
episodes = []
|
||||||
# Try to find episode links
|
# Try to find episode links
|
||||||
episode_links = soup.find_all('a', href=re.compile(r'episode'))
|
episode_links = soup.find_all("a", href=re.compile(r"episode"))
|
||||||
|
|
||||||
for link in episode_links:
|
for link in episode_links:
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
|
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
|
||||||
if match:
|
if match:
|
||||||
episode_num = match.group(1)
|
episode_num = match.group(1)
|
||||||
if not href.startswith('http'):
|
if not href.startswith("http"):
|
||||||
href = urljoin(anime_url, href)
|
href = urljoin(anime_url, href)
|
||||||
|
|
||||||
episodes.append({'episode': episode_num, 'url': href})
|
episodes.append({"episode": episode_num, "url": href})
|
||||||
|
|
||||||
# Deduplicate and sort
|
# Deduplicate and sort
|
||||||
seen = set()
|
seen = set()
|
||||||
unique_episodes = []
|
unique_episodes = []
|
||||||
for ep in episodes:
|
for ep in episodes:
|
||||||
if ep['episode'] not in seen:
|
if ep["episode"] not in seen:
|
||||||
seen.add(ep['episode'])
|
seen.add(ep["episode"])
|
||||||
unique_episodes.append(ep)
|
unique_episodes.append(ep)
|
||||||
|
|
||||||
unique_episodes.sort(key=lambda x: int(x['episode']))
|
unique_episodes.sort(key=lambda x: int(x["episode"]))
|
||||||
return unique_episodes
|
return unique_episodes
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -153,70 +176,70 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
try:
|
try:
|
||||||
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
|
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
'synopsis': None,
|
"synopsis": None,
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'rating': None,
|
"rating": None,
|
||||||
'release_year': None,
|
"release_year": None,
|
||||||
'studio': None,
|
"studio": None,
|
||||||
'poster_image': None,
|
"poster_image": None,
|
||||||
'banner_image': None,
|
"banner_image": None,
|
||||||
'total_episodes': None,
|
"total_episodes": None,
|
||||||
'status': None,
|
"status": None,
|
||||||
'alternative_titles': []
|
"alternative_titles": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract title and year from h1
|
# Extract title and year from h1
|
||||||
title_elem = soup.find('h1')
|
title_elem = soup.find("h1")
|
||||||
if title_elem:
|
if title_elem:
|
||||||
title_text = title_elem.get_text(strip=True)
|
title_text = title_elem.get_text(strip=True)
|
||||||
# Extract year from title like "Naruto (2002)"
|
# Extract year from title like "Naruto (2002)"
|
||||||
year_match = re.search(r'\((\d{4})\)', title_text)
|
year_match = re.search(r"\((\d{4})\)", title_text)
|
||||||
if year_match:
|
if year_match:
|
||||||
metadata['release_year'] = int(year_match.group(1))
|
metadata["release_year"] = int(year_match.group(1))
|
||||||
|
|
||||||
# Extract synopsis - Gupy shows it as paragraphs
|
# Extract synopsis - Gupy shows it as paragraphs
|
||||||
synopsis_elem = soup.find('p')
|
synopsis_elem = soup.find("p")
|
||||||
if synopsis_elem:
|
if synopsis_elem:
|
||||||
text = synopsis_elem.get_text(strip=True)
|
text = synopsis_elem.get_text(strip=True)
|
||||||
if len(text) > 50:
|
if len(text) > 50:
|
||||||
metadata['synopsis'] = text
|
metadata["synopsis"] = text
|
||||||
|
|
||||||
# Extract genres from meta tags or links
|
# Extract genres from meta tags or links
|
||||||
genre_links = soup.find_all('a', href=re.compile(r'serie-|genre|tag'))
|
genre_links = soup.find_all("a", href=re.compile(r"serie-|genre|tag"))
|
||||||
if genre_links:
|
if genre_links:
|
||||||
genres = []
|
genres = []
|
||||||
for link in genre_links[:5]:
|
for link in genre_links[:5]:
|
||||||
text = link.get_text(strip=True)
|
text = link.get_text(strip=True)
|
||||||
if text and '/' not in text and len(text) < 30:
|
if text and "/" not in text and len(text) < 30:
|
||||||
genres.append(text)
|
genres.append(text)
|
||||||
metadata['genres'] = genres
|
metadata["genres"] = genres
|
||||||
|
|
||||||
# Extract rating from percentage
|
# Extract rating from percentage
|
||||||
rating_elem = soup.find(string=re.compile(r'\d+(\.\d+)?%'))
|
rating_elem = soup.find(string=re.compile(r"\d+(\.\d+)?%"))
|
||||||
if rating_elem:
|
if rating_elem:
|
||||||
match = re.search(r'(\d+(\.\d+)?)%', rating_elem)
|
match = re.search(r"(\d+(\.\d+)?)%", rating_elem)
|
||||||
if match:
|
if match:
|
||||||
rating = float(match.group(1)) / 10
|
rating = float(match.group(1)) / 10
|
||||||
metadata['rating'] = f"{rating:.1f}/10"
|
metadata["rating"] = f"{rating:.1f}/10"
|
||||||
|
|
||||||
# Extract poster image
|
# Extract poster image
|
||||||
poster_elem = soup.find('img', src=re.compile(r'poster|poster'))
|
poster_elem = soup.find("img", src=re.compile(r"poster|poster"))
|
||||||
if poster_elem:
|
if poster_elem:
|
||||||
metadata['poster_image'] = poster_elem.get('src')
|
metadata["poster_image"] = poster_elem.get("src")
|
||||||
|
|
||||||
# Extract episode count from page text
|
# Extract episode count from page text
|
||||||
page_text = soup.get_text()
|
page_text = soup.get_text()
|
||||||
ep_match = re.search(r'(\d+)\s*episodes?', page_text, re.I)
|
ep_match = re.search(r"(\d+)\s*episodes?", page_text, re.I)
|
||||||
if ep_match:
|
if ep_match:
|
||||||
metadata['total_episodes'] = int(ep_match.group(1))
|
metadata["total_episodes"] = int(ep_match.group(1))
|
||||||
|
|
||||||
# Extract studio/director
|
# Extract studio/director
|
||||||
director_elem = soup.find('a', href=re.compile(r'person|réalisé'))
|
director_elem = soup.find("a", href=re.compile(r"person|réalisé"))
|
||||||
if director_elem:
|
if director_elem:
|
||||||
metadata['studio'] = director_elem.get_text(strip=True)
|
metadata["studio"] = director_elem.get_text(strip=True)
|
||||||
|
|
||||||
print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
|
print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
|
||||||
return metadata
|
return metadata
|
||||||
@@ -225,16 +248,19 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
print(f"[NEKO-SAMA] Error extracting metadata: {e}")
|
print(f"[NEKO-SAMA] Error extracting metadata: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
async def search_anime(
|
||||||
|
self, query: str, lang: str = "vostfr", include_metadata: bool = False
|
||||||
|
) -> list[dict]:
|
||||||
"""Search for anime on neko-sama (uses Gupy backend)."""
|
"""Search for anime on neko-sama (uses Gupy backend)."""
|
||||||
try:
|
try:
|
||||||
import time
|
import time
|
||||||
from html import unescape
|
from html import unescape
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
|
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
|
||||||
|
|
||||||
# Neko-Sama now uses Gupy - try the direct URL pattern
|
# Neko-Sama now uses Gupy - try the direct URL pattern
|
||||||
search_slug = query.lower().replace(' ', '-')
|
search_slug = query.lower().replace(" ", "-")
|
||||||
search_urls = [
|
search_urls = [
|
||||||
f"https://www.gupy.fr/series/{search_slug}/",
|
f"https://www.gupy.fr/series/{search_slug}/",
|
||||||
f"https://neko-sama.org/series/{search_slug}/",
|
f"https://neko-sama.org/series/{search_slug}/",
|
||||||
@@ -250,34 +276,40 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
print(f"[NEKO-SAMA] Found anime at {final_url}")
|
print(f"[NEKO-SAMA] Found anime at {final_url}")
|
||||||
|
|
||||||
# Extract title from page
|
# Extract title from page
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
title_elem = soup.find('h1') or soup.find('title')
|
title_elem = soup.find("h1") or soup.find("title")
|
||||||
title = unescape(title_elem.get_text(strip=True)) if title_elem else query
|
title = (
|
||||||
|
unescape(title_elem.get_text(strip=True))
|
||||||
|
if title_elem
|
||||||
|
else query
|
||||||
|
)
|
||||||
# Clean up title
|
# Clean up title
|
||||||
title = title.split('|')[0].split('-')[0].strip()
|
title = title.split("|")[0].split("-")[0].strip()
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'title': title,
|
"title": title,
|
||||||
'url': final_url,
|
"url": final_url,
|
||||||
'cover_image': None,
|
"cover_image": None,
|
||||||
'type': 'direct',
|
"type": "direct",
|
||||||
'metadata': None
|
"metadata": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to get poster
|
# Try to get poster
|
||||||
poster = soup.find('img', src=re.compile(r'poster'))
|
poster = soup.find("img", src=re.compile(r"poster"))
|
||||||
if poster:
|
if poster:
|
||||||
result['cover_image'] = poster.get('src')
|
result["cover_image"] = poster.get("src")
|
||||||
|
|
||||||
if include_metadata:
|
if include_metadata:
|
||||||
metadata = await self.get_anime_metadata(final_url)
|
metadata = await self.get_anime_metadata(final_url)
|
||||||
result['metadata'] = metadata
|
result["metadata"] = metadata
|
||||||
|
|
||||||
results.append(result)
|
results.append(result)
|
||||||
break
|
break
|
||||||
|
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
print(f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results")
|
print(
|
||||||
|
f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results"
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
|
|
||||||
BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"]
|
BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.id = "vostfree"
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||||
|
|
||||||
@@ -16,35 +20,35 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
"""Extract download link from vostfree URL"""
|
"""Extract download link from vostfree URL"""
|
||||||
try:
|
try:
|
||||||
response = await self.client.get(url, follow_redirects=True)
|
response = await self.client.get(url, follow_redirects=True)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
# Method 1: Look for iframe players
|
# Method 1: Look for iframe players
|
||||||
iframes = soup.find_all('iframe')
|
iframes = soup.find_all("iframe")
|
||||||
for iframe in iframes:
|
for iframe in iframes:
|
||||||
src = iframe.get('src', '')
|
src = iframe.get("src", "")
|
||||||
if src and any(p in src for p in ['player', 'video', 'stream']):
|
if src and any(p in src for p in ["player", "video", "stream"]):
|
||||||
if not src.startswith('http'):
|
if not src.startswith("http"):
|
||||||
src = urljoin(str(response.url), src)
|
src = urljoin(str(response.url), src)
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 2: Look for video tags
|
# Method 2: Look for video tags
|
||||||
videos = soup.find_all('video')
|
videos = soup.find_all("video")
|
||||||
for video in videos:
|
for video in videos:
|
||||||
src = video.get('src')
|
src = video.get("src")
|
||||||
if src:
|
if src:
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
sources = video.find_all('source')
|
sources = video.find_all("source")
|
||||||
for source in sources:
|
for source in sources:
|
||||||
src = source.get('src', '')
|
src = source.get("src", "")
|
||||||
if src and any(ext in src for ext in ['mp4', 'm3u8']):
|
if src and any(ext in src for ext in ["mp4", "m3u8"]):
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 3: Look in scripts
|
# Method 3: Look in scripts
|
||||||
scripts = soup.find_all('script')
|
scripts = soup.find_all("script")
|
||||||
for script in scripts:
|
for script in scripts:
|
||||||
if script.string:
|
if script.string:
|
||||||
patterns = [
|
patterns = [
|
||||||
@@ -56,8 +60,8 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
matches = re.findall(pattern, script.string)
|
matches = re.findall(pattern, script.string)
|
||||||
for match in matches:
|
for match in matches:
|
||||||
match = match.replace('\\/', '/')
|
match = match.replace("\\/", "/")
|
||||||
if any(ext in match for ext in ['mp4', 'm3u8']):
|
if any(ext in match for ext in ["mp4", "m3u8"]):
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return match, filename
|
return match, filename
|
||||||
|
|
||||||
@@ -67,12 +71,12 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
raise Exception(f"Error extracting Vostfree link: {str(e)}")
|
raise Exception(f"Error extracting Vostfree link: {str(e)}")
|
||||||
|
|
||||||
def _generate_filename(self, url: str) -> str:
|
def _generate_filename(self, url: str) -> str:
|
||||||
parts = url.split('/')
|
parts = url.split("/")
|
||||||
anime_name = "anime"
|
anime_name = "anime"
|
||||||
episode = "1"
|
episode = "1"
|
||||||
|
|
||||||
for part in parts:
|
for part in parts:
|
||||||
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
|
match = re.search(r"episode[-\s]*(\d+)", part, re.I)
|
||||||
if match:
|
if match:
|
||||||
episode = match.group(1)
|
episode = match.group(1)
|
||||||
|
|
||||||
@@ -82,30 +86,30 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
|
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
|
||||||
try:
|
try:
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
episodes = []
|
episodes = []
|
||||||
episode_links = soup.find_all('a', href=re.compile(r'episode', re.I))
|
episode_links = soup.find_all("a", href=re.compile(r"episode", re.I))
|
||||||
|
|
||||||
for link in episode_links:
|
for link in episode_links:
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
|
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
|
||||||
if match:
|
if match:
|
||||||
episode_num = match.group(1)
|
episode_num = match.group(1)
|
||||||
if not href.startswith('http'):
|
if not href.startswith("http"):
|
||||||
href = urljoin(anime_url, href)
|
href = urljoin(anime_url, href)
|
||||||
|
|
||||||
episodes.append({'episode': episode_num, 'url': href})
|
episodes.append({"episode": episode_num, "url": href})
|
||||||
|
|
||||||
# Deduplicate and sort
|
# Deduplicate and sort
|
||||||
seen = set()
|
seen = set()
|
||||||
unique_episodes = []
|
unique_episodes = []
|
||||||
for ep in episodes:
|
for ep in episodes:
|
||||||
if ep['episode'] not in seen:
|
if ep["episode"] not in seen:
|
||||||
seen.add(ep['episode'])
|
seen.add(ep["episode"])
|
||||||
unique_episodes.append(ep)
|
unique_episodes.append(ep)
|
||||||
|
|
||||||
unique_episodes.sort(key=lambda x: int(x['episode']))
|
unique_episodes.sort(key=lambda x: int(x["episode"]))
|
||||||
return unique_episodes
|
return unique_episodes
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -119,29 +123,29 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
try:
|
try:
|
||||||
print(f"[VOSTFREE] Extracting metadata from: {anime_url}")
|
print(f"[VOSTFREE] Extracting metadata from: {anime_url}")
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
'synopsis': None,
|
"synopsis": None,
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'rating': None,
|
"rating": None,
|
||||||
'release_year': None,
|
"release_year": None,
|
||||||
'studio': None,
|
"studio": None,
|
||||||
'poster_image': None,
|
"poster_image": None,
|
||||||
'banner_image': None,
|
"banner_image": None,
|
||||||
'total_episodes': None,
|
"total_episodes": None,
|
||||||
'status': None,
|
"status": None,
|
||||||
'alternative_titles': []
|
"alternative_titles": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract synopsis
|
# Extract synopsis
|
||||||
synopsis_selectors = [
|
synopsis_selectors = [
|
||||||
'div.synopsis',
|
"div.synopsis",
|
||||||
'div.description',
|
"div.description",
|
||||||
'div[class*="synopsis"]',
|
'div[class*="synopsis"]',
|
||||||
'div[class*="desc"]',
|
'div[class*="desc"]',
|
||||||
'p.synopsis',
|
"p.synopsis",
|
||||||
'.anime-synopsis'
|
".anime-synopsis",
|
||||||
]
|
]
|
||||||
|
|
||||||
for selector in synopsis_selectors:
|
for selector in synopsis_selectors:
|
||||||
@@ -149,57 +153,65 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
if synopsis_elem:
|
if synopsis_elem:
|
||||||
synopsis = synopsis_elem.get_text(strip=True)
|
synopsis = synopsis_elem.get_text(strip=True)
|
||||||
if len(synopsis) > 50:
|
if len(synopsis) > 50:
|
||||||
metadata['synopsis'] = synopsis
|
metadata["synopsis"] = synopsis
|
||||||
break
|
break
|
||||||
|
|
||||||
# Extract genres
|
# Extract genres
|
||||||
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I))
|
genre_links = soup.find_all("a", href=re.compile(r"genre|tag|type", re.I))
|
||||||
if genre_links:
|
if genre_links:
|
||||||
metadata['genres'] = [link.get_text(strip=True) for link in genre_links[:5]]
|
metadata["genres"] = [
|
||||||
|
link.get_text(strip=True) for link in genre_links[:5]
|
||||||
|
]
|
||||||
|
|
||||||
# Extract rating
|
# Extract rating
|
||||||
rating_selectors = [
|
rating_selectors = [
|
||||||
'span.rating',
|
"span.rating",
|
||||||
'div.rating',
|
"div.rating",
|
||||||
'span.score',
|
"span.score",
|
||||||
'div[class*="rating"]',
|
'div[class*="rating"]',
|
||||||
'div[class*="score"]'
|
'div[class*="score"]',
|
||||||
]
|
]
|
||||||
|
|
||||||
for selector in rating_selectors:
|
for selector in rating_selectors:
|
||||||
rating_elem = soup.select_one(selector)
|
rating_elem = soup.select_one(selector)
|
||||||
if rating_elem:
|
if rating_elem:
|
||||||
rating_text = rating_elem.get_text(strip=True)
|
rating_text = rating_elem.get_text(strip=True)
|
||||||
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
|
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
|
||||||
if rating_match:
|
if rating_match:
|
||||||
metadata['rating'] = f"{rating_match.group(1)}/10"
|
metadata["rating"] = f"{rating_match.group(1)}/10"
|
||||||
break
|
break
|
||||||
|
|
||||||
# Extract release year
|
# Extract release year
|
||||||
page_text = soup.get_text()
|
page_text = soup.get_text()
|
||||||
year_matches = re.findall(r'\b(19\d{2}|20\d{2})\b', page_text)
|
year_matches = re.findall(r"\b(19\d{2}|20\d{2})\b", page_text)
|
||||||
if year_matches:
|
if year_matches:
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
current_year = datetime.datetime.now().year + 2
|
current_year = datetime.datetime.now().year + 2
|
||||||
valid_years = [int(y) for y in year_matches if 1950 <= int(y) <= current_year]
|
valid_years = [
|
||||||
|
int(y) for y in year_matches if 1950 <= int(y) <= current_year
|
||||||
|
]
|
||||||
if valid_years:
|
if valid_years:
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
metadata['release_year'] = Counter(valid_years).most_common(1)[0][0]
|
|
||||||
|
metadata["release_year"] = Counter(valid_years).most_common(1)[0][0]
|
||||||
|
|
||||||
# Extract poster image
|
# Extract poster image
|
||||||
poster_elem = soup.select_one('img.poster, img.cover, .anime-poster img')
|
poster_elem = soup.select_one("img.poster, img.cover, .anime-poster img")
|
||||||
if poster_elem:
|
if poster_elem:
|
||||||
metadata['poster_image'] = poster_elem.get('src') or poster_elem.get('data-src')
|
metadata["poster_image"] = poster_elem.get("src") or poster_elem.get(
|
||||||
|
"data-src"
|
||||||
|
)
|
||||||
|
|
||||||
# Extract poster from og:image
|
# Extract poster from og:image
|
||||||
og_image = soup.find('meta', property='og:image')
|
og_image = soup.find("meta", property="og:image")
|
||||||
if og_image and not metadata['poster_image']:
|
if og_image and not metadata["poster_image"]:
|
||||||
metadata['poster_image'] = og_image.get('content')
|
metadata["poster_image"] = og_image.get("content")
|
||||||
|
|
||||||
# Extract total episodes
|
# Extract total episodes
|
||||||
episodes_count = len(await self.get_episodes(anime_url))
|
episodes_count = len(await self.get_episodes(anime_url))
|
||||||
if episodes_count > 0:
|
if episodes_count > 0:
|
||||||
metadata['total_episodes'] = episodes_count
|
metadata["total_episodes"] = episodes_count
|
||||||
|
|
||||||
print(f"[VOSTFREE] Extracted metadata: {metadata}")
|
print(f"[VOSTFREE] Extracted metadata: {metadata}")
|
||||||
return metadata
|
return metadata
|
||||||
@@ -208,7 +220,9 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
print(f"[VOSTFREE] Error extracting metadata: {e}")
|
print(f"[VOSTFREE] Error extracting metadata: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
async def search_anime(
|
||||||
|
self, query: str, lang: str = "vostfr", include_metadata: bool = False
|
||||||
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Search for anime on vostfree
|
Search for anime on vostfree
|
||||||
|
|
||||||
@@ -219,6 +233,7 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import time
|
import time
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
print(f"[VOSTFREE] Searching for '{query}' ({lang})...")
|
print(f"[VOSTFREE] Searching for '{query}' ({lang})...")
|
||||||
|
|
||||||
@@ -233,15 +248,15 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
print(f"[VOSTFREE] Found anime at {str(response.url)}")
|
print(f"[VOSTFREE] Found anime at {str(response.url)}")
|
||||||
result = {
|
result = {
|
||||||
'title': query,
|
"title": query,
|
||||||
'url': str(response.url),
|
"url": str(response.url),
|
||||||
'type': 'direct',
|
"type": "direct",
|
||||||
'metadata': None
|
"metadata": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if include_metadata:
|
if include_metadata:
|
||||||
metadata = await self.get_anime_metadata(str(response.url))
|
metadata = await self.get_anime_metadata(str(response.url))
|
||||||
result['metadata'] = metadata
|
result["metadata"] = metadata
|
||||||
|
|
||||||
return [result]
|
return [result]
|
||||||
|
|
||||||
|
|||||||
+128
-139
@@ -1,4 +1,5 @@
|
|||||||
"""FS7 (French Stream) series site downloader"""
|
"""FS7 (French Stream) series site downloader"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
@@ -19,29 +20,46 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.base_url = "https://fs7.lol"
|
self.id = "fs7"
|
||||||
self.search_url = f"{self.base_url}/"
|
self.provider_id = "fs7"
|
||||||
# Update client headers to mimic browser
|
self.default_domain = "fs7.lol"
|
||||||
self.client.headers.update({
|
self.test_tlds = ["lol", "com", "net", "org", "tv", "ws", "cc", "co"]
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
self.base_url = f"https://{self.default_domain}"
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
self._domain_checked = False
|
||||||
'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7',
|
self.client.headers.update(
|
||||||
'Accept-Encoding': 'gzip, deflate',
|
{
|
||||||
'Connection': 'keep-alive',
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
'Upgrade-Insecure-Requests': '1'
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||||
})
|
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
|
"Accept-Encoding": "gzip, deflate",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _ensure_base_url(self):
|
||||||
|
"""Ensure base_url is set to the current active domain"""
|
||||||
|
if self._domain_checked:
|
||||||
|
return
|
||||||
|
self._domain_checked = True
|
||||||
|
try:
|
||||||
|
from app.utils import DomainManager
|
||||||
|
|
||||||
|
active_domain = await DomainManager.get_active_domain(
|
||||||
|
self.provider_id, self.default_domain, self.test_tlds, test_path="/"
|
||||||
|
)
|
||||||
|
self.base_url = f"https://{active_domain}"
|
||||||
|
logger.info(f"Using active domain for FS7: {self.base_url}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Domain check failed for FS7, using default: {e}")
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
"""Check if this downloader can handle the given URL"""
|
"""Check if this downloader can handle the given URL"""
|
||||||
return "fs7.lol" in url.lower() or "french-stream" in url.lower()
|
return "fs7.lol" in url.lower() or "french-stream" in url.lower()
|
||||||
|
|
||||||
async def search_anime(
|
async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
lang: str = "vf"
|
|
||||||
) -> List[Dict[str, str]]:
|
|
||||||
"""
|
"""
|
||||||
Search for series on FS7.
|
Search for series on FS7 using DLE AJAX search endpoint.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query
|
query: Search query
|
||||||
@@ -51,91 +69,61 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
List of series with title, url, cover_image
|
List of series with title, url, cover_image
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
await self._ensure_base_url()
|
||||||
logger.info(f"Searching FS7 for: {query}")
|
logger.info(f"Searching FS7 for: {query}")
|
||||||
|
|
||||||
# FS7 uses GET request with query parameters for search
|
ajax_url = f"{self.base_url}/engine/ajax/search.php"
|
||||||
response = await self.client.get(
|
response = await self.client.post(
|
||||||
self.search_url,
|
ajax_url,
|
||||||
params={
|
data={"query": query, "page": "1"},
|
||||||
"do": "search",
|
headers={
|
||||||
"subaction": "search",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"story": query
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
}
|
"Referer": f"{self.base_url}/",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Look for series items
|
for item in soup.find_all("div", class_="search-item")[:24]:
|
||||||
# FS7 usually structure: <div class="movie-item">...<a href="..."><img src="..."></a>...</div>
|
onclick = item.get("onclick", "")
|
||||||
# Or directly <a> tags with images
|
url_match = re.search(r"location\.href=['\"]([^'\"]+)['\"]", onclick)
|
||||||
items = soup.find_all('div', class_='movie-item')
|
if not url_match:
|
||||||
if not items:
|
|
||||||
# Fallback to the previous method if layout is different
|
|
||||||
items = soup.find_all('a', href=re.compile(r'/s-tv/\d+-.+\.html'))
|
|
||||||
|
|
||||||
for item in items[:24]: # Limit to 24 results
|
|
||||||
# Find the link and image within the item or the item itself
|
|
||||||
if item.name == 'a':
|
|
||||||
link_elem = item
|
|
||||||
else:
|
|
||||||
link_elem = item.find('a', href=re.compile(r'/s-tv/|/films/'))
|
|
||||||
|
|
||||||
if not link_elem:
|
|
||||||
continue
|
continue
|
||||||
|
url = url_match.group(1)
|
||||||
url = link_elem.get('href', '')
|
if not url.startswith("http"):
|
||||||
if not url.startswith('http'):
|
|
||||||
url = urljoin(self.base_url, url)
|
url = urljoin(self.base_url, url)
|
||||||
|
|
||||||
# Extract title
|
title_elem = item.find("div", class_="search-title")
|
||||||
img_elem = item.find('img')
|
title = title_elem.get_text(strip=True) if title_elem else ""
|
||||||
title = ""
|
title = re.sub(r"\s+", " ", title).strip()
|
||||||
if img_elem and img_elem.get('alt'):
|
|
||||||
title = img_elem.get('alt').strip()
|
|
||||||
elif link_elem.get('title'):
|
|
||||||
title = link_elem.get('title').strip()
|
|
||||||
else:
|
|
||||||
title = item.get_text(strip=True)
|
|
||||||
|
|
||||||
# Extract cover image
|
|
||||||
img_elem = item.find('img')
|
|
||||||
cover_image = ""
|
cover_image = ""
|
||||||
if img_elem:
|
poster_elem = item.find("div", class_="search-poster")
|
||||||
# Check for common lazy loading attributes used by various themes
|
if poster_elem:
|
||||||
cover_image = (
|
img = poster_elem.find("img")
|
||||||
img_elem.get('data-src') or
|
if img:
|
||||||
img_elem.get('data-original') or
|
cover_image = (
|
||||||
img_elem.get('src') or
|
img.get("data-src")
|
||||||
""
|
or img.get("data-original")
|
||||||
)
|
or img.get("src")
|
||||||
|
or ""
|
||||||
# If still empty, look for background-style images in inline styles
|
)
|
||||||
if not cover_image:
|
|
||||||
style = item.get('style', '')
|
|
||||||
if 'background-image' in style:
|
|
||||||
match = re.search(r'url\([\'"]?(.*?)[\'"]?\)', style)
|
|
||||||
if match:
|
|
||||||
cover_image = match.group(1)
|
|
||||||
|
|
||||||
if cover_image and not cover_image.startswith('http'):
|
|
||||||
cover_image = urljoin(self.base_url, cover_image)
|
|
||||||
|
|
||||||
# Clean up title
|
|
||||||
title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip()
|
|
||||||
title = re.sub(r'\s+', ' ', title)
|
|
||||||
|
|
||||||
if title and len(title) > 2:
|
if title and len(title) > 2:
|
||||||
if not any(r['url'] == url for r in results):
|
results.append(
|
||||||
results.append({
|
{
|
||||||
'title': title,
|
"title": title,
|
||||||
'url': url,
|
"url": url,
|
||||||
'cover_image': cover_image
|
"cover_image": cover_image,
|
||||||
})
|
"provider_id": self.provider_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Found {len(results)} series on FS7")
|
logger.info(f"Found {len(results)} results on FS7 for '{query}'")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -143,9 +131,7 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_episodes(
|
async def get_episodes(
|
||||||
self,
|
self, anime_url: str, lang: str = "vf"
|
||||||
anime_url: str,
|
|
||||||
lang: str = "vf"
|
|
||||||
) -> List[Dict[str, str]]:
|
) -> List[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Get episode list for a series.
|
Get episode list for a series.
|
||||||
@@ -164,31 +150,33 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
episodes = []
|
episodes = []
|
||||||
|
|
||||||
# Get series title for episode naming
|
# Get series title for episode naming
|
||||||
title_elem = soup.find('h1')
|
title_elem = soup.find("h1")
|
||||||
series_title = title_elem.get_text(strip=True) if title_elem else "Series"
|
series_title = title_elem.get_text(strip=True) if title_elem else "Series"
|
||||||
# Clean up title: remove "affiche" suffix
|
# Clean up title: remove "affiche" suffix
|
||||||
series_title = re.sub(r'\s+affiche$', '', series_title, flags=re.IGNORECASE).strip()
|
series_title = re.sub(
|
||||||
|
r"\s+affiche$", "", series_title, flags=re.IGNORECASE
|
||||||
|
).strip()
|
||||||
|
|
||||||
# FS7 stores episode data in JavaScript div elements
|
# FS7 stores episode data in JavaScript div elements
|
||||||
# Format: <div data-ep="1" data-vidzy="..." data-uqload="..." data-netu="..." data-voe="..."></div>
|
# Format: <div data-ep="1" data-vidzy="..." data-uqload="..." data-netu="..." data-voe="..."></div>
|
||||||
episode_divs = soup.find_all('div', attrs={'data-ep': True})
|
episode_divs = soup.find_all("div", attrs={"data-ep": True})
|
||||||
|
|
||||||
for div in episode_divs:
|
for div in episode_divs:
|
||||||
ep_num = div.get('data-ep', '').strip()
|
ep_num = div.get("data-ep", "").strip()
|
||||||
|
|
||||||
# Try different video players in order of preference
|
# Try different video players in order of preference
|
||||||
video_url = None
|
video_url = None
|
||||||
host_name = None
|
host_name = None
|
||||||
for player in ['data-vidzy', 'data-uqload', 'data-voe', 'data-netu']:
|
for player in ["data-vidzy", "data-uqload", "data-voe", "data-netu"]:
|
||||||
player_url = div.get(player, '').strip()
|
player_url = div.get(player, "").strip()
|
||||||
if player_url:
|
if player_url:
|
||||||
video_url = player_url
|
video_url = player_url
|
||||||
# Extract host name from attribute name
|
# Extract host name from attribute name
|
||||||
host_name = player.replace('data-', '').title()
|
host_name = player.replace("data-", "").title()
|
||||||
logger.debug(f"Found episode {ep_num} on {host_name}")
|
logger.debug(f"Found episode {ep_num} on {host_name}")
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -199,15 +187,19 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
# Use pipe-separated format: video_url|anime_url|episode_title
|
# Use pipe-separated format: video_url|anime_url|episode_title
|
||||||
combined_url = f"{video_url}|{anime_url}|{episode_title}"
|
combined_url = f"{video_url}|{anime_url}|{episode_title}"
|
||||||
|
|
||||||
episodes.append({
|
episodes.append(
|
||||||
'episode': ep_num,
|
{
|
||||||
'url': combined_url,
|
"episode": ep_num,
|
||||||
'title': episode_title,
|
"url": combined_url,
|
||||||
'host': host_name or 'Unknown'
|
"title": episode_title,
|
||||||
})
|
"host": host_name or "Unknown",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Sort by episode number
|
# Sort by episode number
|
||||||
episodes.sort(key=lambda x: int(x['episode']) if x['episode'].isdigit() else 0)
|
episodes.sort(
|
||||||
|
key=lambda x: int(x["episode"]) if x["episode"].isdigit() else 0
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Found {len(episodes)} episodes")
|
logger.info(f"Found {len(episodes)} episodes")
|
||||||
return episodes
|
return episodes
|
||||||
@@ -216,10 +208,7 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
logger.error(f"Error getting episodes from FS7: {e}")
|
logger.error(f"Error getting episodes from FS7: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_anime_metadata(
|
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
|
||||||
self,
|
|
||||||
anime_url: str
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
Get metadata for a series.
|
Get metadata for a series.
|
||||||
|
|
||||||
@@ -236,62 +225,62 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
# Extract title
|
# Extract title
|
||||||
title = soup.find('h1')
|
title = soup.find("h1")
|
||||||
title = title.get_text(strip=True) if title else "Unknown"
|
title = title.get_text(strip=True) if title else "Unknown"
|
||||||
|
|
||||||
# Clean up title: remove "affiche" suffix
|
# Clean up title: remove "affiche" suffix
|
||||||
title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip()
|
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
# Extract description/synopsis
|
# Extract description/synopsis
|
||||||
description_elem = soup.find('div', class_='full-text')
|
description_elem = soup.find("div", class_="full-text")
|
||||||
description = description_elem.get_text(strip=True) if description_elem else ""
|
description = (
|
||||||
|
description_elem.get_text(strip=True) if description_elem else ""
|
||||||
|
)
|
||||||
|
|
||||||
# Extract cover image
|
# Extract cover image
|
||||||
img = soup.find('img', class_='poster')
|
img = soup.find("img", class_="poster")
|
||||||
poster_image = img.get('src', '') if img else ''
|
poster_image = img.get("src", "") if img else ""
|
||||||
|
|
||||||
# Try to get poster from meta tag if not found
|
# Try to get poster from meta tag if not found
|
||||||
if not poster_image:
|
if not poster_image:
|
||||||
meta_img = soup.find('meta', property='og:image')
|
meta_img = soup.find("meta", property="og:image")
|
||||||
poster_image = meta_img.get('content', '') if meta_img else ''
|
poster_image = meta_img.get("content", "") if meta_img else ""
|
||||||
|
|
||||||
# Extract year
|
# Extract year
|
||||||
year_match = re.search(r'\b(19|20)\d{2}\b', description)
|
year_match = re.search(r"\b(19|20)\d{2}\b", description)
|
||||||
release_year = int(year_match.group()) if year_match else None
|
release_year = int(year_match.group()) if year_match else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'title': title,
|
"title": title,
|
||||||
'synopsis': description,
|
"synopsis": description,
|
||||||
'poster_image': poster_image,
|
"poster_image": poster_image,
|
||||||
'release_year': release_year,
|
"release_year": release_year,
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'rating': None,
|
"rating": None,
|
||||||
'studio': None,
|
"studio": None,
|
||||||
'total_episodes': None,
|
"total_episodes": None,
|
||||||
'status': None
|
"status": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting metadata from FS7: {e}")
|
logger.error(f"Error getting metadata from FS7: {e}")
|
||||||
return {
|
return {
|
||||||
'title': "Unknown",
|
"title": "Unknown",
|
||||||
'synopsis': "",
|
"synopsis": "",
|
||||||
'poster_image': '',
|
"poster_image": "",
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'rating': None,
|
"rating": None,
|
||||||
'release_year': None,
|
"release_year": None,
|
||||||
'studio': None,
|
"studio": None,
|
||||||
'total_episodes': None,
|
"total_episodes": None,
|
||||||
'status': None
|
"status": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_download_link(
|
async def get_download_link(
|
||||||
self,
|
self, url: str, target_filename: Optional[str] = None
|
||||||
url: str,
|
|
||||||
target_filename: Optional[str] = None
|
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Extract download link from video player URL.
|
Extract download link from video player URL.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Zone-Telechargement series site downloader"""
|
"""Zone-Telechargement series site downloader"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
@@ -18,94 +19,106 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.id = "zonetelechargement"
|
||||||
self.provider_id = "zonetelechargement"
|
self.provider_id = "zonetelechargement"
|
||||||
self.default_domain = "zone-telechargement.cam"
|
self.default_domain = "zone-telechargement.golf"
|
||||||
self.test_tlds = ["cam", "net", "org", "blue", "lol", "work"]
|
self.test_tlds = ["golf", "cam", "net", "org", "blue", "lol", "work", "ws"]
|
||||||
self.base_url = None # Will be set dynamically
|
self.base_url = f"https://{self.default_domain}"
|
||||||
|
self._domain_checked = False
|
||||||
self.client.headers.update({
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
self.client.headers.update(
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
{
|
||||||
'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7',
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
})
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||||
|
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def _ensure_base_url(self):
|
async def _ensure_base_url(self):
|
||||||
"""Ensure base_url is set to the current active domain"""
|
"""Ensure base_url is set to the current active domain"""
|
||||||
if not self.base_url:
|
if self._domain_checked:
|
||||||
|
return
|
||||||
|
self._domain_checked = True
|
||||||
|
try:
|
||||||
active_domain = await DomainManager.get_active_domain(
|
active_domain = await DomainManager.get_active_domain(
|
||||||
self.provider_id,
|
self.provider_id, self.default_domain, self.test_tlds, test_path="/"
|
||||||
self.default_domain,
|
|
||||||
self.test_tlds,
|
|
||||||
test_path="/"
|
|
||||||
)
|
)
|
||||||
self.base_url = f"https://{active_domain}"
|
self.base_url = f"https://{active_domain}"
|
||||||
logger.info(f"Using active domain for Zone-Telechargement: {self.base_url}")
|
logger.info(f"Using active domain for Zone-Telechargement: {self.base_url}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Domain check failed for Zone-Telechargement, using default: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
"""Check if this downloader can handle the given URL"""
|
"""Check if this downloader can handle the given URL"""
|
||||||
return "zone-telechargement" in url.lower() or "zt-za" in url.lower()
|
return "zone-telechargement" in url.lower() or "zt-za" in url.lower()
|
||||||
|
|
||||||
async def search_anime(
|
async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
|
||||||
self,
|
"""Search for series on Zone-Telechargement.
|
||||||
query: str,
|
|
||||||
lang: str = "vf"
|
ZT uses server-side rendered search: GET /?p=series&search=QUERY.
|
||||||
) -> List[Dict[str, str]]:
|
Results are in div.cover_global containers with nested cover_infos_title links.
|
||||||
"""Search for series on Zone-Telechargement"""
|
"""
|
||||||
try:
|
try:
|
||||||
await self._ensure_base_url()
|
await self._ensure_base_url()
|
||||||
logger.info(f"Searching Zone-Telechargement for: {query}")
|
logger.info(f"Searching Zone-Telechargement for: {query}")
|
||||||
|
|
||||||
# ZT uses POST or GET for search depending on the version
|
search_url = f"{self.base_url}/"
|
||||||
# Most modern versions use: /index.php?do=search
|
params = {"p": "series", "search": query}
|
||||||
search_url = f"{self.base_url}/index.php?do=search"
|
|
||||||
|
response = await self.client.get(search_url, params=params)
|
||||||
# Form data for search
|
|
||||||
data = {
|
|
||||||
"do": "search",
|
|
||||||
"subaction": "search",
|
|
||||||
"search_start": "0",
|
|
||||||
"full_search": "0",
|
|
||||||
"result_from": "1",
|
|
||||||
"story": query
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await self.client.post(search_url, data=data)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Look for items
|
for cover_div in soup.find_all("div", class_="cover_global")[:24]:
|
||||||
items = soup.find_all('div', class_='shm-item') or soup.find_all('div', class_='movie-item')
|
link_in_cover = cover_div.find("a", class_="mainimg")
|
||||||
|
if not link_in_cover:
|
||||||
for item in items[:24]:
|
link_in_cover = cover_div.find("a")
|
||||||
link_elem = item.find('a', class_='shm-title') or item.find('a')
|
|
||||||
if not link_elem:
|
if not link_in_cover:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
url = link_elem.get('href', '')
|
url = link_in_cover.get("href", "")
|
||||||
if not url.startswith('http'):
|
if not url.startswith("http"):
|
||||||
url = urljoin(self.base_url, url)
|
url = urljoin(self.base_url, url)
|
||||||
|
|
||||||
title = link_elem.get_text(strip=True)
|
img = cover_div.find("img")
|
||||||
|
|
||||||
img_elem = item.find('img')
|
|
||||||
cover_image = ""
|
cover_image = ""
|
||||||
if img_elem:
|
if img:
|
||||||
cover_image = img_elem.get('data-src') or img_elem.get('src') or ""
|
cover_image = img.get("data-src") or img.get("src") or ""
|
||||||
|
if cover_image and not cover_image.startswith("http"):
|
||||||
if cover_image and not cover_image.startswith('http'):
|
cover_image = urljoin(self.base_url, cover_image)
|
||||||
cover_image = urljoin(self.base_url, cover_image)
|
|
||||||
|
title = ""
|
||||||
|
info_div = cover_div.find("div", class_="cover_infos_title")
|
||||||
|
if info_div:
|
||||||
|
title_link = info_div.find("a")
|
||||||
|
if title_link:
|
||||||
|
title = title_link.get_text(strip=True)
|
||||||
|
else:
|
||||||
|
title = info_div.get_text(strip=True)
|
||||||
|
else:
|
||||||
|
title = link_in_cover.get("title", "")
|
||||||
|
if not title:
|
||||||
|
title = link_in_cover.get_text(strip=True)
|
||||||
|
|
||||||
if title and len(title) > 2:
|
if title and len(title) > 2:
|
||||||
results.append({
|
results.append(
|
||||||
'title': title,
|
{
|
||||||
'url': url,
|
"title": title,
|
||||||
'cover_image': cover_image,
|
"url": url,
|
||||||
'provider_id': self.provider_id
|
"cover_image": cover_image,
|
||||||
})
|
"provider_id": self.provider_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Zone-Telechargement found {len(results)} results for '{query}'"
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -113,39 +126,35 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_episodes(
|
async def get_episodes(
|
||||||
self,
|
self, anime_url: str, lang: str = "vf"
|
||||||
anime_url: str,
|
|
||||||
lang: str = "vf"
|
|
||||||
) -> List[Dict[str, str]]:
|
) -> List[Dict[str, str]]:
|
||||||
"""Extract episodes from a series page"""
|
"""Extract episodes from a series page"""
|
||||||
try:
|
try:
|
||||||
await self._ensure_base_url()
|
await self._ensure_base_url()
|
||||||
html = await self._fetch_page(anime_url)
|
html = await self._fetch_page(anime_url)
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
episodes = []
|
episodes = []
|
||||||
|
|
||||||
# ZT typically lists episodes in a table or list of links
|
# ZT typically lists episodes in a table or list of links
|
||||||
# Links often look like: /telecharger-series/.../saison-X-episode-Y.html
|
# Links often look like: /telecharger-series/.../saison-X-episode-Y.html
|
||||||
links = soup.find_all('a', href=re.compile(r'episode-\d+'))
|
links = soup.find_all("a", href=re.compile(r"episode-\d+"))
|
||||||
|
|
||||||
for i, link in enumerate(links):
|
for i, link in enumerate(links):
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
if not href.startswith('http'):
|
if not href.startswith("http"):
|
||||||
href = urljoin(self.base_url, href)
|
href = urljoin(self.base_url, href)
|
||||||
|
|
||||||
title = link.get_text(strip=True)
|
title = link.get_text(strip=True)
|
||||||
ep_match = re.search(r'episode\s*(\d+)', title.lower())
|
ep_match = re.search(r"episode\s*(\d+)", title.lower())
|
||||||
ep_number = int(ep_match.group(1)) if ep_match else i + 1
|
ep_number = int(ep_match.group(1)) if ep_match else i + 1
|
||||||
|
|
||||||
episodes.append({
|
episodes.append(
|
||||||
'episode_number': ep_number,
|
{"episode_number": ep_number, "url": href, "title": title}
|
||||||
'url': href,
|
)
|
||||||
'title': title
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by episode number
|
# Sort by episode number
|
||||||
episodes.sort(key=lambda x: x['episode_number'])
|
episodes.sort(key=lambda x: x["episode_number"])
|
||||||
return episodes
|
return episodes
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -157,32 +166,40 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
|
|||||||
try:
|
try:
|
||||||
await self._ensure_base_url()
|
await self._ensure_base_url()
|
||||||
html = await self._fetch_page(anime_url)
|
html = await self._fetch_page(anime_url)
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
'title': "",
|
"title": "",
|
||||||
'synopsis': "",
|
"synopsis": "",
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'poster_image': "",
|
"poster_image": "",
|
||||||
'status': "Unknown"
|
"status": "Unknown",
|
||||||
}
|
}
|
||||||
|
|
||||||
title_elem = soup.find('h1')
|
title_elem = soup.find("h1")
|
||||||
if title_elem:
|
if title_elem:
|
||||||
metadata['title'] = title_elem.get_text(strip=True)
|
metadata["title"] = title_elem.get_text(strip=True)
|
||||||
|
|
||||||
# Synopsis
|
# Synopsis
|
||||||
syn_elem = soup.find('div', class_='shm-description') or soup.find('div', class_='movie-desc')
|
syn_elem = soup.find("div", class_="shm-description") or soup.find(
|
||||||
|
"div", class_="movie-desc"
|
||||||
|
)
|
||||||
if syn_elem:
|
if syn_elem:
|
||||||
metadata['synopsis'] = syn_elem.get_text(strip=True)
|
metadata["synopsis"] = syn_elem.get_text(strip=True)
|
||||||
|
|
||||||
# Poster
|
# Poster
|
||||||
img_elem = soup.find('div', class_='shm-img').find('img') if soup.find('div', class_='shm-img') else None
|
img_elem = (
|
||||||
|
soup.find("div", class_="shm-img").find("img")
|
||||||
|
if soup.find("div", class_="shm-img")
|
||||||
|
else None
|
||||||
|
)
|
||||||
if img_elem:
|
if img_elem:
|
||||||
metadata['poster_image'] = urljoin(self.base_url, img_elem.get('src', ''))
|
metadata["poster_image"] = urljoin(
|
||||||
|
self.base_url, img_elem.get("src", "")
|
||||||
|
)
|
||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting metadata from Zone-Telechargement: {e}")
|
logger.error(f"Error getting metadata from Zone-Telechargement: {e}")
|
||||||
return {}
|
return {}
|
||||||
@@ -192,19 +209,25 @@ class ZoneTelechargementDownloader(BaseSeriesSite):
|
|||||||
try:
|
try:
|
||||||
await self._ensure_base_url()
|
await self._ensure_base_url()
|
||||||
html = await self._fetch_page(url)
|
html = await self._fetch_page(url)
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
# Look for video player links (Uptobox, 1fichier, etc.)
|
# Look for video player links (Uptobox, 1fichier, etc.)
|
||||||
# ZT often has multiple hosts
|
# ZT often has multiple hosts
|
||||||
links = soup.find_all('a', href=re.compile(r'uptobox|1fichier|doodstream|vidmoly'))
|
links = soup.find_all(
|
||||||
|
"a", href=re.compile(r"uptobox|1fichier|doodstream|vidmoly")
|
||||||
|
)
|
||||||
|
|
||||||
if links:
|
if links:
|
||||||
player_url = links[0].get('href', '')
|
player_url = links[0].get("href", "")
|
||||||
title = soup.find('h1').get_text(strip=True) if soup.find('h1') else "Episode"
|
title = (
|
||||||
|
soup.find("h1").get_text(strip=True)
|
||||||
|
if soup.find("h1")
|
||||||
|
else "Episode"
|
||||||
|
)
|
||||||
return player_url, title
|
return player_url, title
|
||||||
|
|
||||||
return "", ""
|
return "", ""
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting download link from Zone-Telechargement: {e}")
|
logger.error(f"Error getting download link from Zone-Telechargement: {e}")
|
||||||
return "", ""
|
return "", ""
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
# Video Players (app/downloaders/video_players)
|
# Video Players (app/downloaders/video_players/)
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
File hosting extractors that extract direct download links from video player pages (Doodstream, Sibnet, VidMoly, etc.).
|
File hosting extractors that extract direct download links from video player pages (Doodstream, Sibnet, VidMoly, Uptobox, etc.).
|
||||||
|
|
||||||
## WHERE TO LOOK
|
## WHERE TO LOOK
|
||||||
|
|
||||||
| Need | File |
|
| File | Purpose |
|
||||||
|------|------|
|
|------|---------|
|
||||||
| Base class | `base.py` - `BaseVideoPlayer` abstract class |
|
| `base.py` | `BaseVideoPlayer` abstract class |
|
||||||
| Add new player | Create new `.py` file, inherit `BaseVideoPlayer`, add to `__init__.py` |
|
| `unfichier.py` | 1fichier.com |
|
||||||
| URL detection logic | Each player's `can_handle()` method |
|
| `doodstream.py` | Doodstream |
|
||||||
| Extract download link | Each player's `get_download_link()` method |
|
| `vidmoly.py` | VidMoly (requires Playwright for extraction) |
|
||||||
|
| `uptobox.py` | Uptobox |
|
||||||
|
| `sendvid.py` | SendVid |
|
||||||
|
| `sibnet.py` | Sibnet |
|
||||||
|
| `rapidfile.py` | Rapidfile |
|
||||||
|
| `uqload.py` | Uqload |
|
||||||
|
| `lpayer.py` | Lplayer |
|
||||||
|
| `vidzy.py` | Vidzy |
|
||||||
|
| `luluv.py` | LuLuvid |
|
||||||
|
| `smoothpre.py` | Smoothpre |
|
||||||
|
| `oneupload.py` | OneUpload |
|
||||||
|
|
||||||
## CONVENTIONS
|
## CONVENTIONS
|
||||||
|
|
||||||
@@ -22,16 +32,17 @@ def can_handle(self, url: str) -> bool: ...
|
|||||||
async def get_download_link(self, url: str, target_filename: str = None) -> tuple[str, str]: ...
|
async def get_download_link(self, url: str, target_filename: str = None) -> tuple[str, str]: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
**File operation**: Always use `sanitize_filename()` on extracted filenames.
|
|
||||||
|
|
||||||
**HTTP client**: Use `self.client` (AsyncClient from base class). Always close via `await self.close()` when done.
|
|
||||||
|
|
||||||
**Return format**: `(download_url, filename)` tuple.
|
**Return format**: `(download_url, filename)` tuple.
|
||||||
|
|
||||||
|
**HTTP client**: Use `self.client` (AsyncClient from base class). Always close via `await self.close()`.
|
||||||
|
|
||||||
|
**File operation**: Always `sanitize_filename()` on extracted filenames.
|
||||||
|
|
||||||
## ANTI-PATTERNS
|
## ANTI-PATTERNS
|
||||||
|
|
||||||
- Do NOT hardcode User-Agent in each player (use base class headers)
|
- Do NOT hardcode User-Agent per player — use base class headers
|
||||||
- Do NOT forget to call `await self.close()` after extraction
|
- Do NOT forget `await self.close()` — resource leak
|
||||||
- Do NOT return None for missing URLs, raise an exception
|
- Do NOT return None for missing URLs — raise an exception
|
||||||
- Do NOT use sync `requests`, use async `httpx`
|
- Do NOT use sync `requests` — use async `httpx`
|
||||||
- Do NOT skip the `target_filename` parameter, even if unused
|
- Do NOT skip `target_filename` parameter — required for anime/series site compatibility
|
||||||
|
- 8 empty `except:` blocks across players — known tech debt
|
||||||
|
|||||||
@@ -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
@@ -3,56 +3,94 @@
|
|||||||
ANIME_PROVIDERS = {
|
ANIME_PROVIDERS = {
|
||||||
"anime-sama": {
|
"anime-sama": {
|
||||||
"name": "Anime-Sama",
|
"name": "Anime-Sama",
|
||||||
"domains": ["anime-sama.to", "www.anime-sama.to", "anime-sama.tv", "www.anime-sama.tv", "anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"],
|
"domains": [
|
||||||
|
"anime-sama.to",
|
||||||
|
"www.anime-sama.to",
|
||||||
|
"anime-sama.tv",
|
||||||
|
"www.anime-sama.tv",
|
||||||
|
"anime-sama.si",
|
||||||
|
"www.anime-sama.si",
|
||||||
|
"anime-sama.org",
|
||||||
|
"anime-sama.store",
|
||||||
|
"anime-sama.eu",
|
||||||
|
],
|
||||||
"url_pattern": "https://anime-sama.to/catalogue/{anime}/saison{season}/{lang}/",
|
"url_pattern": "https://anime-sama.to/catalogue/{anime}/saison{season}/{lang}/",
|
||||||
"icon": "🎬",
|
"icon": "🎬",
|
||||||
"color": "#00d9ff"
|
"color": "#00d9ff",
|
||||||
},
|
},
|
||||||
"anime-ultime": {
|
"anime-ultime": {
|
||||||
"name": "Anime-Ultime",
|
"name": "Anime-Ultime",
|
||||||
"domains": ["anime-ultime.net", "anime-ultime.com", "www.anime-ultime.net"],
|
"domains": ["anime-ultime.net", "anime-ultime.com", "www.anime-ultime.net"],
|
||||||
"url_pattern": "https://www.anime-ultime.net/info-{id}-{slug}",
|
"url_pattern": "https://www.anime-ultime.net/info-{id}-{slug}",
|
||||||
"icon": "▶️",
|
"icon": "▶️",
|
||||||
"color": "#00ff88"
|
"color": "#00ff88",
|
||||||
},
|
},
|
||||||
"neko-sama": {
|
"neko-sama": {
|
||||||
"name": "Neko-Sama",
|
"name": "Neko-Sama",
|
||||||
"domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"],
|
"domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"],
|
||||||
"url_pattern": "https://neko-sama.fr/anime/{slug}",
|
"url_pattern": "https://neko-sama.fr/anime/{slug}",
|
||||||
"icon": "🐱",
|
"icon": "🐱",
|
||||||
"color": "#ff6b6b"
|
"color": "#ff6b6b",
|
||||||
},
|
},
|
||||||
"vostfree": {
|
"vostfree": {
|
||||||
"name": "Vostfree",
|
"name": "Vostfree",
|
||||||
"domains": ["vostfree.tv", "www.vostfree.tv"],
|
"domains": ["vostfree.tv", "www.vostfree.tv"],
|
||||||
"url_pattern": "https://vostfree.tv/anime/{slug}",
|
"url_pattern": "https://vostfree.tv/anime/{slug}",
|
||||||
"icon": "📺",
|
"icon": "📺",
|
||||||
"color": "#ffd93d"
|
"color": "#ffd93d",
|
||||||
},
|
},
|
||||||
"french-manga": {
|
"french-manga": {
|
||||||
"name": "French-Manga",
|
"name": "French-Manga",
|
||||||
"domains": ["french-manga.net", "w16.french-manga.net", "w15.french-manga.net", "www.french-manga.net"],
|
"domains": [
|
||||||
|
"french-manga.net",
|
||||||
|
"w16.french-manga.net",
|
||||||
|
"w15.french-manga.net",
|
||||||
|
"www.french-manga.net",
|
||||||
|
],
|
||||||
"url_pattern": "https://w16.french-manga.net/{slug}.html",
|
"url_pattern": "https://w16.french-manga.net/{slug}.html",
|
||||||
"icon": "🇫🇷",
|
"icon": "🇫🇷",
|
||||||
"color": "#ff7675"
|
"color": "#ff7675",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
SERIES_PROVIDERS = {
|
SERIES_PROVIDERS = {
|
||||||
"fs7": {
|
"fs7": {
|
||||||
"name": "French Stream",
|
"name": "French Stream",
|
||||||
"domains": ["fs7.lol", "www.fs7.lol", "french-stream.tv", "www.french-stream.tv"],
|
"domains": [
|
||||||
|
"fs7.lol",
|
||||||
|
"www.fs7.lol",
|
||||||
|
"french-stream.tv",
|
||||||
|
"www.french-stream.tv",
|
||||||
|
"fs7.com",
|
||||||
|
"fs7.net",
|
||||||
|
"fs7.org",
|
||||||
|
"fs7.cc",
|
||||||
|
"fs7.co",
|
||||||
|
"french-stream.com",
|
||||||
|
"french-stream.net",
|
||||||
|
],
|
||||||
"url_pattern": "https://fs7.lol/s-tv/{slug}.html",
|
"url_pattern": "https://fs7.lol/s-tv/{slug}.html",
|
||||||
"icon": "🎬",
|
"icon": "🎬",
|
||||||
"color": "#ff6b9d"
|
"color": "#ff6b9d",
|
||||||
},
|
},
|
||||||
"zonetelechargement": {
|
"zonetelechargement": {
|
||||||
"name": "Zone-Telechargement",
|
"name": "Zone-Telechargement",
|
||||||
"domains": ["zone-telechargement.cam", "zone-telechargement.net", "zone-telechargement.org", "zone-telechargement.blue", "zone-telechargement.lol", "zone-telechargement.work"],
|
"domains": [
|
||||||
"url_pattern": "https://zone-telechargement.cam/index.php?do=search",
|
"zone-telechargement.golf",
|
||||||
|
"zone-telechargement.cam",
|
||||||
|
"zone-telechargement.net",
|
||||||
|
"zone-telechargement.org",
|
||||||
|
"zone-telechargement.blue",
|
||||||
|
"zone-telechargement.lol",
|
||||||
|
"zone-telechargement.work",
|
||||||
|
"zone-telechargement.ws",
|
||||||
|
"www.zone-telechargement.golf",
|
||||||
|
"www.zone-telechargement.cam",
|
||||||
|
],
|
||||||
|
"url_pattern": "https://zone-telechargement.golf/index.php?do=search",
|
||||||
"icon": "⬇️",
|
"icon": "⬇️",
|
||||||
"color": "#00d9ff"
|
"color": "#00d9ff",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
FILE_HOSTS = {
|
FILE_HOSTS = {
|
||||||
@@ -60,98 +98,112 @@ FILE_HOSTS = {
|
|||||||
"name": "1fichier",
|
"name": "1fichier",
|
||||||
"domains": ["1fichier.com", "1fichier.fr"],
|
"domains": ["1fichier.com", "1fichier.fr"],
|
||||||
"icon": "📁",
|
"icon": "📁",
|
||||||
"color": "#4ecdc4"
|
"color": "#4ecdc4",
|
||||||
},
|
},
|
||||||
"uptobox": {
|
"uptobox": {
|
||||||
"name": "Uptobox",
|
"name": "Uptobox",
|
||||||
"domains": ["uptobox.com", "uptobox.fr"],
|
"domains": ["uptobox.com", "uptobox.fr"],
|
||||||
"icon": "📦",
|
"icon": "📦",
|
||||||
"color": "#45b7d1"
|
"color": "#45b7d1",
|
||||||
},
|
},
|
||||||
"doodstream": {
|
"doodstream": {
|
||||||
"name": "Doodstream",
|
"name": "Doodstream",
|
||||||
"domains": ["doodstream.com", "dood.stream", "dood.to", "dood.lol", "dood.cx", "dood.so", "dood.watch", "dood.sh"],
|
"domains": [
|
||||||
|
"doodstream.com",
|
||||||
|
"dood.stream",
|
||||||
|
"dood.to",
|
||||||
|
"dood.lol",
|
||||||
|
"dood.cx",
|
||||||
|
"dood.so",
|
||||||
|
"dood.watch",
|
||||||
|
"dood.sh",
|
||||||
|
],
|
||||||
"icon": "🎥",
|
"icon": "🎥",
|
||||||
"color": "#f7b731"
|
"color": "#f7b731",
|
||||||
},
|
},
|
||||||
"rapidfile": {
|
"rapidfile": {
|
||||||
"name": "Rapidfile",
|
"name": "Rapidfile",
|
||||||
"domains": ["rapidfile.net", "rapidfile.com"],
|
"domains": ["rapidfile.net", "rapidfile.com"],
|
||||||
"icon": "⚡",
|
"icon": "⚡",
|
||||||
"color": "#ff6b6b"
|
"color": "#ff6b6b",
|
||||||
},
|
},
|
||||||
"vidmoly": {
|
"vidmoly": {
|
||||||
"name": "VidMoly",
|
"name": "VidMoly",
|
||||||
"domains": ["vidmoly.to", "vidmoly.org", "vidmoly.biz"],
|
"domains": ["vidmoly.to", "vidmoly.org", "vidmoly.biz"],
|
||||||
"icon": "🎬",
|
"icon": "🎬",
|
||||||
"color": "#a29bfe"
|
"color": "#a29bfe",
|
||||||
},
|
},
|
||||||
"sendvid": {
|
"sendvid": {
|
||||||
"name": "SendVid",
|
"name": "SendVid",
|
||||||
"domains": ["sendvid.com", "sendvid.io"],
|
"domains": ["sendvid.com", "sendvid.io"],
|
||||||
"icon": "📤",
|
"icon": "📤",
|
||||||
"color": "#fd79a8"
|
"color": "#fd79a8",
|
||||||
},
|
},
|
||||||
"sibnet": {
|
"sibnet": {
|
||||||
"name": "Sibnet",
|
"name": "Sibnet",
|
||||||
"domains": ["sibnet.ru", "video.sibnet.ru"],
|
"domains": ["sibnet.ru", "video.sibnet.ru"],
|
||||||
"icon": "🎞️",
|
"icon": "🎞️",
|
||||||
"color": "#00cec9"
|
"color": "#00cec9",
|
||||||
},
|
},
|
||||||
"lpayer": {
|
"lpayer": {
|
||||||
"name": "Lplayer",
|
"name": "Lplayer",
|
||||||
"domains": ["lpayer.embed4me.com", "lpayer.com", "lplayer.fr"],
|
"domains": ["lpayer.embed4me.com", "lpayer.com", "lplayer.fr"],
|
||||||
"icon": "▶️",
|
"icon": "▶️",
|
||||||
"color": "#e17055"
|
"color": "#e17055",
|
||||||
},
|
},
|
||||||
"vidzy": {
|
"vidzy": {
|
||||||
"name": "Vidzy",
|
"name": "Vidzy",
|
||||||
"domains": ["vidzy.com", "vidzy.net", "www.vidzy.com"],
|
"domains": ["vidzy.com", "vidzy.net", "www.vidzy.com"],
|
||||||
"icon": "🎞️",
|
"icon": "🎞️",
|
||||||
"color": "#74b9ff"
|
"color": "#74b9ff",
|
||||||
},
|
},
|
||||||
"luluv": {
|
"luluv": {
|
||||||
"name": "LuLuvid",
|
"name": "LuLuvid",
|
||||||
"domains": ["luluv.com", "luluvid.com", "www.luluv.com", "www.luluvid.com"],
|
"domains": ["luluv.com", "luluvid.com", "www.luluv.com", "www.luluvid.com"],
|
||||||
"icon": "🎬",
|
"icon": "🎬",
|
||||||
"color": "#a29bfe"
|
"color": "#a29bfe",
|
||||||
},
|
},
|
||||||
"uqload": {
|
"uqload": {
|
||||||
"name": "Uqload",
|
"name": "Uqload",
|
||||||
"domains": ["uqload.bz", "uqload.com", "www.uqload.bz", "www.uqload.com"],
|
"domains": ["uqload.bz", "uqload.com", "www.uqload.bz", "www.uqload.com"],
|
||||||
"icon": "📺",
|
"icon": "📺",
|
||||||
"color": "#fd79a8"
|
"color": "#fd79a8",
|
||||||
},
|
},
|
||||||
"smoothpre": {
|
"smoothpre": {
|
||||||
"name": "Smoothpre",
|
"name": "Smoothpre",
|
||||||
"domains": ["smoothpre.com", "www.smoothpre.com"],
|
"domains": ["smoothpre.com", "www.smoothpre.com"],
|
||||||
"icon": "🎬",
|
"icon": "🎬",
|
||||||
"color": "#a29bfe"
|
"color": "#a29bfe",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_all_providers():
|
def get_all_providers():
|
||||||
"""Get all supported providers (anime + series + file hosts)"""
|
"""Get all supported providers (anime + series + file hosts)"""
|
||||||
return {**ANIME_PROVIDERS, **SERIES_PROVIDERS, **FILE_HOSTS}
|
return {**ANIME_PROVIDERS, **SERIES_PROVIDERS, **FILE_HOSTS}
|
||||||
|
|
||||||
|
|
||||||
def get_anime_providers():
|
def get_anime_providers():
|
||||||
"""Get all anime streaming providers"""
|
"""Get all anime streaming providers"""
|
||||||
return ANIME_PROVIDERS
|
return ANIME_PROVIDERS
|
||||||
|
|
||||||
|
|
||||||
def get_series_providers():
|
def get_series_providers():
|
||||||
"""Get all series streaming providers"""
|
"""Get all series streaming providers"""
|
||||||
return SERIES_PROVIDERS
|
return SERIES_PROVIDERS
|
||||||
|
|
||||||
|
|
||||||
def get_file_hosts():
|
def get_file_hosts():
|
||||||
"""Get all file hosting providers"""
|
"""Get all file hosting providers"""
|
||||||
return FILE_HOSTS
|
return FILE_HOSTS
|
||||||
|
|
||||||
|
|
||||||
def detect_provider_from_url(url: str) -> str | None:
|
def detect_provider_from_url(url: str) -> str | None:
|
||||||
"""Detect which provider can handle the given URL"""
|
"""Detect which provider can handle the given URL"""
|
||||||
url_lower = url.lower()
|
url_lower = url.lower()
|
||||||
|
|
||||||
for provider_id, provider in get_all_providers().items():
|
for provider_id, provider in get_all_providers().items():
|
||||||
for domain in provider['domains']:
|
for domain in provider["domains"]:
|
||||||
if domain in url_lower:
|
if domain in url_lower:
|
||||||
return provider_id
|
return provider_id
|
||||||
|
|
||||||
|
|||||||
+89
-13
@@ -1,4 +1,5 @@
|
|||||||
"""Manages scraper providers and their health status"""
|
"""Manages scraper providers and their health status"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -7,6 +8,18 @@ from pathlib import Path
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.downloaders.generic_scraper import GenericScraper
|
from app.downloaders.generic_scraper import GenericScraper
|
||||||
|
from app.downloaders.anime_sites import (
|
||||||
|
AnimeSamaDownloader,
|
||||||
|
NekoSamaDownloader,
|
||||||
|
AnimeUltimeDownloader,
|
||||||
|
VostfreeDownloader,
|
||||||
|
FrenchMangaDownloader,
|
||||||
|
)
|
||||||
|
from app.downloaders.series_sites import (
|
||||||
|
FS7Downloader,
|
||||||
|
ZoneTelechargementDownloader,
|
||||||
|
)
|
||||||
|
from app.providers import ANIME_PROVIDERS, SERIES_PROVIDERS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -16,11 +29,13 @@ class ProvidersManager:
|
|||||||
|
|
||||||
def __init__(self, config_dir: str = "app/downloaders/providers_config"):
|
def __init__(self, config_dir: str = "app/downloaders/providers_config"):
|
||||||
self.config_dir = Path(config_dir)
|
self.config_dir = Path(config_dir)
|
||||||
self.providers: Dict[str, GenericScraper] = {}
|
self.providers: Dict[str, object] = {}
|
||||||
|
self.provider_info: Dict[str, Dict] = {}
|
||||||
self.health_status: Dict[str, Dict] = {}
|
self.health_status: Dict[str, Dict] = {}
|
||||||
self._load_providers()
|
self._load_yaml_providers()
|
||||||
|
self._load_hardcoded_providers()
|
||||||
|
|
||||||
def _load_providers(self):
|
def _load_yaml_providers(self):
|
||||||
"""Load all providers from YAML configs"""
|
"""Load all providers from YAML configs"""
|
||||||
if not self.config_dir.exists():
|
if not self.config_dir.exists():
|
||||||
logger.warning(f"Providers config directory not found: {self.config_dir}")
|
logger.warning(f"Providers config directory not found: {self.config_dir}")
|
||||||
@@ -33,46 +48,107 @@ class ProvidersManager:
|
|||||||
self.health_status[scraper.id] = {
|
self.health_status[scraper.id] = {
|
||||||
"status": "unknown",
|
"status": "unknown",
|
||||||
"last_check": None,
|
"last_check": None,
|
||||||
"error": None
|
"error": None,
|
||||||
}
|
}
|
||||||
logger.info(f"Loaded provider: {scraper.name} ({scraper.id})")
|
logger.info(f"Loaded YAML provider: {scraper.name} ({scraper.id})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load provider from {config_file}: {e}")
|
logger.error(f"Failed to load provider from {config_file}: {e}")
|
||||||
|
|
||||||
|
def _load_hardcoded_providers(self):
|
||||||
|
"""Load hardcoded Python providers"""
|
||||||
|
provider_classes = [
|
||||||
|
("anime-sama", AnimeSamaDownloader, ANIME_PROVIDERS),
|
||||||
|
("neko-sama", NekoSamaDownloader, ANIME_PROVIDERS),
|
||||||
|
("anime-ultime", AnimeUltimeDownloader, ANIME_PROVIDERS),
|
||||||
|
("vostfree", VostfreeDownloader, ANIME_PROVIDERS),
|
||||||
|
("french-manga", FrenchMangaDownloader, ANIME_PROVIDERS),
|
||||||
|
("fs7", FS7Downloader, SERIES_PROVIDERS),
|
||||||
|
("zonetelechargement", ZoneTelechargementDownloader, SERIES_PROVIDERS),
|
||||||
|
]
|
||||||
|
|
||||||
|
for provider_id, provider_class, provider_dict in provider_classes:
|
||||||
|
if provider_id in provider_dict:
|
||||||
|
try:
|
||||||
|
self.providers[provider_id] = provider_class()
|
||||||
|
self.provider_info[provider_id] = provider_dict[provider_id]
|
||||||
|
self.health_status[provider_id] = {
|
||||||
|
"status": "unknown",
|
||||||
|
"last_check": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
logger.info(f"Loaded hardcoded provider: {provider_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load provider {provider_id}: {e}")
|
||||||
|
|
||||||
async def check_all_health(self):
|
async def check_all_health(self):
|
||||||
"""Check health of all registered providers"""
|
"""Check health of all registered providers"""
|
||||||
logger.info("Checking health of all providers...")
|
logger.info("Checking health of all providers...")
|
||||||
tasks = []
|
tasks = []
|
||||||
for provider_id, scraper in self.providers.items():
|
for provider_id, scraper in self.providers.items():
|
||||||
tasks.append(self._check_single_health(provider_id, scraper))
|
tasks.append(self._check_single_health(provider_id, scraper))
|
||||||
|
|
||||||
await asyncio.gather(*tasks)
|
await asyncio.gather(*tasks)
|
||||||
logger.info("Provider health check complete")
|
logger.info("Provider health check complete")
|
||||||
|
|
||||||
async def _check_single_health(self, provider_id: str, scraper: GenericScraper):
|
async def _check_single_health(self, provider_id: str, scraper):
|
||||||
"""Check health of a single provider and update status"""
|
"""Check health of a single provider and update status"""
|
||||||
try:
|
try:
|
||||||
is_healthy = await scraper.check_health()
|
is_healthy = await self._do_health_check(scraper)
|
||||||
self.health_status[provider_id] = {
|
self.health_status[provider_id] = {
|
||||||
"status": "up" if is_healthy else "down",
|
"status": "up" if is_healthy else "down",
|
||||||
"last_check": datetime.now().isoformat(),
|
"last_check": datetime.now().isoformat(),
|
||||||
"error": None if is_healthy else "No search results returned"
|
"error": None if is_healthy else "No search results returned",
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.health_status[provider_id] = {
|
self.health_status[provider_id] = {
|
||||||
"status": "down",
|
"status": "down",
|
||||||
"last_check": datetime.now().isoformat(),
|
"last_check": datetime.now().isoformat(),
|
||||||
"error": str(e)
|
"error": str(e),
|
||||||
}
|
}
|
||||||
logger.error(f"Health check failed for {provider_id}: {e}")
|
logger.error(f"Health check failed for {provider_id}: {e}")
|
||||||
|
|
||||||
def get_provider(self, provider_id: str) -> Optional[GenericScraper]:
|
async def _do_health_check(self, scraper) -> bool:
|
||||||
|
"""Perform health check on a scraper"""
|
||||||
|
try:
|
||||||
|
if hasattr(scraper, "check_health"):
|
||||||
|
return await scraper.check_health()
|
||||||
|
elif hasattr(scraper, "client"):
|
||||||
|
# Test basic connectivity
|
||||||
|
base_url = getattr(scraper, "base_url", None) or getattr(
|
||||||
|
scraper, "active_url", None
|
||||||
|
)
|
||||||
|
if base_url:
|
||||||
|
if hasattr(scraper, "_ensure_base_url"):
|
||||||
|
await scraper._ensure_base_url()
|
||||||
|
base_url = getattr(scraper, "base_url", base_url)
|
||||||
|
response = await scraper.client.get(base_url, timeout=15.0)
|
||||||
|
return 200 <= response.status_code < 400
|
||||||
|
elif hasattr(scraper, "BASE_DOMAINS") and scraper.BASE_DOMAINS:
|
||||||
|
# Test first domain from BASE_DOMAINS
|
||||||
|
test_url = f"https://{scraper.BASE_DOMAINS[0]}"
|
||||||
|
response = await scraper.client.get(test_url, timeout=15.0)
|
||||||
|
return 200 <= response.status_code < 400
|
||||||
|
elif hasattr(scraper, "search_anime"):
|
||||||
|
results = await scraper.search_anime("One Piece", lang="vostfr")
|
||||||
|
return len(results) > 0
|
||||||
|
elif hasattr(scraper, "search"):
|
||||||
|
results = await scraper.search("One Piece")
|
||||||
|
return len(results) > 0
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Health check exception for {getattr(scraper, 'provider_id', scraper)}: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_provider(self, provider_id: str):
|
||||||
return self.providers.get(provider_id)
|
return self.providers.get(provider_id)
|
||||||
|
|
||||||
def get_active_providers(self) -> List[GenericScraper]:
|
def get_active_providers(self) -> List:
|
||||||
"""Return only providers that are UP or UNKNOWN"""
|
"""Return only providers that are UP or UNKNOWN"""
|
||||||
return [
|
return [
|
||||||
self.providers[pid] for pid, status in self.health_status.items()
|
self.providers[pid]
|
||||||
|
for pid, status in self.health_status.items()
|
||||||
if status["status"] != "down"
|
if status["status"] != "down"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Routers (app/routers/)
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
11 FastAPI APIRouter modules, each owning a URL prefix. All registered in `main.py:118-144`.
|
||||||
|
|
||||||
|
## WHERE TO LOOK
|
||||||
|
|
||||||
|
| Router | File | Prefix | Purpose |
|
||||||
|
|--------|------|--------|---------|
|
||||||
|
| root_router | `router_root.py` | `/`, `/web` | Index page, web UI |
|
||||||
|
| auth_router | `router_auth.py` | `/api/auth` | Register, login, JWT tokens |
|
||||||
|
| downloads_router | `router_downloads.py` | `/api/download` | Task CRUD, pause/resume, file serve |
|
||||||
|
| anime_router | `router_anime.py` | `/api/anime`, `/api/series` | Search, metadata, episodes, season download |
|
||||||
|
| favorites_router | `router_favorites.py` | `/api/favorites` | Favorites toggle, list |
|
||||||
|
| recommendations_router | `router_recommendations.py` | `/api/recommendations`, `/api/releases` | Personalized + latest releases |
|
||||||
|
| watchlist_router | `router_watchlist.py` | `/api/watchlist` | Watchlist CRUD, scheduler, auto-download |
|
||||||
|
| sonarr_router | `router_sonarr.py` | `/api/sonarr`, `/api/webhook/sonarr` | Webhook receiver, mappings |
|
||||||
|
| player_router | `router_player.py` | `/player`, `/watch` | Video player pages |
|
||||||
|
| static_router | `router_static.py` | `/static`, `/video` | Static files, video streaming (Range) |
|
||||||
|
| settings_router | `router_settings.py` | `/api/settings` | User app settings |
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
|
||||||
|
**Adding endpoints**: Identify the correct router by URL prefix → add to that file → import in `app/routers/__init__.py` (if new router) → register in `main.py`.
|
||||||
|
|
||||||
|
**Shared dependencies** (via FastAPI `Depends`):
|
||||||
|
- `download_manager: DownloadManager = Depends(lambda: download_manager)` — singleton from main.py
|
||||||
|
- `current_user: User = Depends(get_current_user_from_token)` — JWT auth
|
||||||
|
- `templates: Jinja2Templates = Depends(lambda: templates)` — Jinja2 renderer
|
||||||
|
|
||||||
|
**Router registration** in `main.py` uses `app.include_router(router)`. Tags set per-router for OpenAPI.
|
||||||
|
|
||||||
|
## ANTI-PATTERNS
|
||||||
|
|
||||||
|
- Do NOT create a new router for a single endpoint — add to existing matching router
|
||||||
|
- Do NOT use `Depends()` with direct module imports that create circular references
|
||||||
|
- Do NOT duplicate URL prefixes across routers
|
||||||
+91
-44
@@ -9,7 +9,15 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
BackgroundTasks,
|
||||||
|
Depends,
|
||||||
|
HTTPException,
|
||||||
|
Query,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
)
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
@@ -35,10 +43,12 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api", tags=["anime"])
|
router = APIRouter(prefix="/api", tags=["anime"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
# Add custom filters to Jinja2
|
# Add custom filters to Jinja2
|
||||||
def hash_filter(s):
|
def hash_filter(s):
|
||||||
return hashlib.md5(s.encode()).hexdigest()[:10]
|
return hashlib.md5(s.encode()).hexdigest()[:10]
|
||||||
|
|
||||||
|
|
||||||
templates.env.filters["hash"] = hash_filter
|
templates.env.filters["hash"] = hash_filter
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +62,7 @@ async def get_providers_health():
|
|||||||
async def trigger_providers_health_check(background_tasks: BackgroundTasks):
|
async def trigger_providers_health_check(background_tasks: BackgroundTasks):
|
||||||
"""Trigger a manual health check of all providers in the background"""
|
"""Trigger a manual health check of all providers in the background"""
|
||||||
from app.auto_download_scheduler import auto_download_scheduler
|
from app.auto_download_scheduler import auto_download_scheduler
|
||||||
|
|
||||||
background_tasks.add_task(auto_download_scheduler.trigger_health_check_now)
|
background_tasks.add_task(auto_download_scheduler.trigger_health_check_now)
|
||||||
return {"status": "Health check triggered in background"}
|
return {"status": "Health check triggered in background"}
|
||||||
|
|
||||||
@@ -59,6 +70,7 @@ async def trigger_providers_health_check(background_tasks: BackgroundTasks):
|
|||||||
def get_download_manager() -> DownloadManager:
|
def get_download_manager() -> DownloadManager:
|
||||||
"""Get the download manager instance from main app"""
|
"""Get the download manager instance from main app"""
|
||||||
from main import download_manager
|
from main import download_manager
|
||||||
|
|
||||||
return download_manager
|
return download_manager
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +85,7 @@ async def search_anime_unified(
|
|||||||
include_metadata: bool = False,
|
include_metadata: bool = False,
|
||||||
html: bool = Query(False),
|
html: bool = Query(False),
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
session: Session = Depends(get_session)
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Search across all anime providers.
|
Search across all anime providers.
|
||||||
@@ -83,12 +95,14 @@ async def search_anime_unified(
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# Get user settings for disabled providers
|
# Get user settings for disabled providers
|
||||||
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
|
statement = select(AppSettingsTable).where(
|
||||||
|
AppSettingsTable.user_id == current_user.id
|
||||||
|
)
|
||||||
settings_obj = session.exec(statement).first()
|
settings_obj = session.exec(statement).first()
|
||||||
disabled_providers = settings_obj.disabled_providers if settings_obj else []
|
disabled_providers = settings_obj.disabled_providers if settings_obj else []
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
# 1. Prepare search tasks (Generic + Legacy)
|
# 1. Prepare search tasks (Generic + Legacy)
|
||||||
search_tasks = []
|
search_tasks = []
|
||||||
task_metadata = []
|
task_metadata = []
|
||||||
@@ -96,19 +110,26 @@ async def search_anime_unified(
|
|||||||
# Generic YAML providers
|
# Generic YAML providers
|
||||||
active_generic = providers_manager.get_active_providers()
|
active_generic = providers_manager.get_active_providers()
|
||||||
for provider in active_generic:
|
for provider in active_generic:
|
||||||
if provider.id not in disabled_providers:
|
provider_id = getattr(provider, "id", None)
|
||||||
search_tasks.append(provider.search(q))
|
if provider_id and provider_id not in disabled_providers:
|
||||||
task_metadata.append({"id": provider.id, "type": "generic"})
|
if hasattr(provider, "search"):
|
||||||
|
search_tasks.append(provider.search(q))
|
||||||
|
task_metadata.append({"id": provider_id, "type": "generic"})
|
||||||
|
elif hasattr(provider, "search_anime"):
|
||||||
|
search_tasks.append(provider.search_anime(q, lang))
|
||||||
|
task_metadata.append({"id": provider_id, "type": "legacy"})
|
||||||
|
|
||||||
# Legacy providers
|
# Legacy providers (already included in providers_manager, but keep for fallback)
|
||||||
legacy_downloaders = {
|
legacy_downloaders = {
|
||||||
"anime-ultime": AnimeUltimeDownloader(),
|
"anime-ultime": AnimeUltimeDownloader(),
|
||||||
"neko-sama": NekoSamaDownloader(),
|
"neko-sama": NekoSamaDownloader(),
|
||||||
"vostfree": VostfreeDownloader(),
|
"vostfree": VostfreeDownloader(),
|
||||||
}
|
}
|
||||||
for pid, dl in legacy_downloaders.items():
|
for pid, dl in legacy_downloaders.items():
|
||||||
if pid not in disabled_providers:
|
if pid not in disabled_providers and pid not in {
|
||||||
search_tasks.append(dl.search_anime(q, lang, include_metadata=False))
|
getattr(p, "id", None) for p in active_generic
|
||||||
|
}:
|
||||||
|
search_tasks.append(dl.search_anime(q, lang))
|
||||||
task_metadata.append({"id": pid, "type": "legacy"})
|
task_metadata.append({"id": pid, "type": "legacy"})
|
||||||
|
|
||||||
# 2. Run searches in parallel
|
# 2. Run searches in parallel
|
||||||
@@ -118,25 +139,25 @@ async def search_anime_unified(
|
|||||||
seen_urls = set()
|
seen_urls = set()
|
||||||
enricher = await get_metadata_enricher()
|
enricher = await get_metadata_enricher()
|
||||||
enrichment_tasks = []
|
enrichment_tasks = []
|
||||||
enrichment_mapping = []
|
enrichment_mapping = []
|
||||||
|
|
||||||
for i, raw_result in enumerate(all_raw_results):
|
for i, raw_result in enumerate(all_raw_results):
|
||||||
provider_info = task_metadata[i]
|
provider_info = task_metadata[i]
|
||||||
pid = provider_info["id"]
|
pid = provider_info["id"]
|
||||||
|
|
||||||
if isinstance(raw_result, Exception):
|
if isinstance(raw_result, Exception):
|
||||||
logger.error(f"Search failed for {pid}: {raw_result}")
|
logger.error(f"Search failed for {pid}: {raw_result}")
|
||||||
continue
|
continue
|
||||||
if not raw_result:
|
if not raw_result:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if pid not in results:
|
if pid not in results:
|
||||||
results[pid] = []
|
results[pid] = []
|
||||||
|
|
||||||
for item in raw_result:
|
for item in raw_result:
|
||||||
item_dict = item.model_dump() if hasattr(item, "model_dump") else item
|
item_dict = item.model_dump() if hasattr(item, "model_dump") else item
|
||||||
url = item_dict.get("url")
|
url = item_dict.get("url")
|
||||||
|
|
||||||
if url and url not in seen_urls:
|
if url and url not in seen_urls:
|
||||||
seen_urls.add(url)
|
seen_urls.add(url)
|
||||||
if q.lower() in (item_dict.get("title") or "").lower():
|
if q.lower() in (item_dict.get("title") or "").lower():
|
||||||
@@ -144,10 +165,16 @@ async def search_anime_unified(
|
|||||||
else:
|
else:
|
||||||
item_dict["_relevance_boost"] = 0.5
|
item_dict["_relevance_boost"] = 0.5
|
||||||
results[pid].append(item_dict)
|
results[pid].append(item_dict)
|
||||||
|
|
||||||
# Prepare enrichment task for top 5 results per provider
|
# Prepare enrichment task for top 5 results per provider
|
||||||
if len(results[pid]) <= 5:
|
if len(results[pid]) <= 5:
|
||||||
enrichment_tasks.append(enricher.enrich_metadata(item_dict.get("metadata", {}), item_dict.get("title", ""), url))
|
enrichment_tasks.append(
|
||||||
|
enricher.enrich_metadata(
|
||||||
|
item_dict.get("metadata", {}),
|
||||||
|
item_dict.get("title", ""),
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
)
|
||||||
enrichment_mapping.append((pid, len(results[pid]) - 1))
|
enrichment_mapping.append((pid, len(results[pid]) - 1))
|
||||||
else:
|
else:
|
||||||
if "metadata" not in item_dict:
|
if "metadata" not in item_dict:
|
||||||
@@ -170,18 +197,16 @@ async def search_anime_unified(
|
|||||||
|
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
total_found = sum(len(r) for r in results.values())
|
total_found = sum(len(r) for r in results.values())
|
||||||
print(f"[SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}")
|
print(
|
||||||
|
f"[SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}"
|
||||||
|
)
|
||||||
|
|
||||||
# 6. Return HTML for HTMX or JSON for API
|
# 6. Return HTML for HTMX or JSON for API
|
||||||
if html or request.headers.get("HX-Request"):
|
if html or request.headers.get("HX-Request"):
|
||||||
print("[SEARCH] Returning HTML response")
|
print("[SEARCH] Returning HTML response")
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"components/anime_search_results.html",
|
"components/anime_search_results.html",
|
||||||
{
|
{"request": request, "results": results, "settings": settings_obj},
|
||||||
"request": request,
|
|
||||||
"results": results,
|
|
||||||
"settings": settings_obj
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
print("[SEARCH] Returning JSON response")
|
print("[SEARCH] Returning JSON response")
|
||||||
@@ -200,7 +225,7 @@ async def search_series_unified(
|
|||||||
lang: str = "vf",
|
lang: str = "vf",
|
||||||
html: bool = Query(False),
|
html: bool = Query(False),
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
session: Session = Depends(get_session)
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Search across all TV series providers (FS7, etc.)
|
Search across all TV series providers (FS7, etc.)
|
||||||
@@ -213,14 +238,16 @@ async def search_series_unified(
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# Get user settings for disabled providers
|
# Get user settings for disabled providers
|
||||||
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
|
statement = select(AppSettingsTable).where(
|
||||||
|
AppSettingsTable.user_id == current_user.id
|
||||||
|
)
|
||||||
settings_obj = session.exec(statement).first()
|
settings_obj = session.exec(statement).first()
|
||||||
disabled_providers = settings_obj.disabled_providers if settings_obj else []
|
disabled_providers = settings_obj.disabled_providers if settings_obj else []
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
series_downloaders = {
|
series_downloaders = {
|
||||||
"fs7": FS7Downloader(),
|
"fs7": FS7Downloader(),
|
||||||
"zonetelechargement": ZoneTelechargementDownloader()
|
"zonetelechargement": ZoneTelechargementDownloader(),
|
||||||
}
|
}
|
||||||
search_tasks = []
|
search_tasks = []
|
||||||
provider_ids = []
|
provider_ids = []
|
||||||
@@ -236,22 +263,24 @@ async def search_series_unified(
|
|||||||
for provider_id, result in zip(provider_ids, search_results):
|
for provider_id, result in zip(provider_ids, search_results):
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
print(f"[SERIES SEARCH] {provider_id} error: {str(result)}")
|
print(f"[SERIES SEARCH] {provider_id} error: {str(result)}")
|
||||||
|
logger.error(f"Series search error for {provider_id}: {result}")
|
||||||
elif result:
|
elif result:
|
||||||
results[provider_id] = result
|
results[provider_id] = result
|
||||||
|
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
|
||||||
|
else:
|
||||||
|
print(f"[SERIES SEARCH] {provider_id}: No results returned")
|
||||||
|
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
total_found = sum(len(r) for r in results.values())
|
total_found = sum(len(r) for r in results.values())
|
||||||
print(f"[SERIES SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}")
|
print(
|
||||||
|
f"[SERIES SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}"
|
||||||
|
)
|
||||||
|
|
||||||
# Return HTML for HTMX or JSON for API
|
# Return HTML for HTMX or JSON for API
|
||||||
if html or request.headers.get("HX-Request"):
|
if html or request.headers.get("HX-Request"):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"components/series_search_results.html",
|
"components/series_search_results.html",
|
||||||
{
|
{"request": request, "results": results, "settings": settings_obj},
|
||||||
"request": request,
|
|
||||||
"results": results,
|
|
||||||
"settings": settings_obj
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"query": q, "lang": lang, "results": results}
|
return {"query": q, "lang": lang, "results": results}
|
||||||
@@ -266,7 +295,10 @@ async def get_anime_metadata(url: str):
|
|||||||
metadata = await downloader.get_anime_metadata(url)
|
metadata = await downloader.get_anime_metadata(url)
|
||||||
return {"url": url, "metadata": metadata}
|
return {"url": url, "metadata": metadata}
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail=f"Downloader for {url} does not support metadata extraction")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Downloader for {url} does not support metadata extraction",
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@@ -284,7 +316,7 @@ async def get_anime_episodes(
|
|||||||
"""
|
"""
|
||||||
downloader = get_downloader(url)
|
downloader = get_downloader(url)
|
||||||
episodes = await downloader.get_episodes(url, lang)
|
episodes = await downloader.get_episodes(url, lang)
|
||||||
|
|
||||||
if html or request.headers.get("HX-Request"):
|
if html or request.headers.get("HX-Request"):
|
||||||
# Extract title from first episode or URL for the display
|
# Extract title from first episode or URL for the display
|
||||||
anime_title = "Épisodes"
|
anime_title = "Épisodes"
|
||||||
@@ -297,12 +329,12 @@ async def get_anime_episodes(
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"components/episode_list.html",
|
"components/episode_list.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"episodes": episodes,
|
"episodes": episodes,
|
||||||
"anime_url": url,
|
"anime_url": url,
|
||||||
"anime_title": anime_title,
|
"anime_title": anime_title,
|
||||||
"lang": lang
|
"lang": lang,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"url": url, "lang": lang, "episodes": episodes}
|
return {"url": url, "lang": lang, "episodes": episodes}
|
||||||
@@ -329,10 +361,17 @@ async def download_anime_episode(
|
|||||||
request = DownloadRequest(url=url)
|
request = DownloadRequest(url=url)
|
||||||
task = download_manager.create_task(request)
|
task = download_manager.create_task(request)
|
||||||
background_tasks.add_task(download_manager.start_download, task.id)
|
background_tasks.add_task(download_manager.start_download, task.id)
|
||||||
|
|
||||||
# Add toast notification for HTMX
|
# Add toast notification for HTMX
|
||||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"Téléchargement lancé : {task.filename}", "type": "success"}})
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
|
{
|
||||||
|
"show-toast": {
|
||||||
|
"message": f"Téléchargement lancé : {task.filename}",
|
||||||
|
"type": "success",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {"task_id": task.id, "task": task}
|
return {"task_id": task.id, "task": task}
|
||||||
|
|
||||||
|
|
||||||
@@ -381,6 +420,7 @@ async def search_anime_mal_details(
|
|||||||
):
|
):
|
||||||
"""Search for anime on MyAnimeList and get full details"""
|
"""Search for anime on MyAnimeList and get full details"""
|
||||||
from app.recommendations import AnimeReleasesFetcher
|
from app.recommendations import AnimeReleasesFetcher
|
||||||
|
|
||||||
fetcher = AnimeReleasesFetcher()
|
fetcher = AnimeReleasesFetcher()
|
||||||
try:
|
try:
|
||||||
search_results = await fetcher.search_anime(q, limit=limit)
|
search_results = await fetcher.search_anime(q, limit=limit)
|
||||||
@@ -401,6 +441,7 @@ async def search_anime_mal_details(
|
|||||||
async def translate_text(request: Request):
|
async def translate_text(request: Request):
|
||||||
"""Translate text from English to French using Google Translate"""
|
"""Translate text from English to French using Google Translate"""
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
text = body.get("text", "")
|
text = body.get("text", "")
|
||||||
@@ -408,7 +449,13 @@ async def translate_text(request: Request):
|
|||||||
raise HTTPException(status_code=400, detail="Text is required")
|
raise HTTPException(status_code=400, detail="Text is required")
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
url = "https://translate.googleapis.com/translate_a/single"
|
url = "https://translate.googleapis.com/translate_a/single"
|
||||||
params = {"client": "gtx", "sl": "en", "tl": "fr", "dt": "t", "q": text[:5000]}
|
params = {
|
||||||
|
"client": "gtx",
|
||||||
|
"sl": "en",
|
||||||
|
"tl": "fr",
|
||||||
|
"dt": "t",
|
||||||
|
"q": text[:5000],
|
||||||
|
}
|
||||||
response = await client.get(url, params=params)
|
response = await client.get(url, params=params)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|||||||
+51
-19
@@ -10,6 +10,7 @@ Endpoints:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
@@ -39,15 +40,48 @@ async def get_current_user_from_token(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
user_dict = user_manager.get_user(username)
|
user = user_manager.get_user(username)
|
||||||
if user_dict is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="User not found",
|
detail="User not found",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
return User(**user_dict)
|
return User(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
email=user.email,
|
||||||
|
full_name=user.full_name,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at,
|
||||||
|
last_login=user.last_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_optional_user(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
|
||||||
|
HTTPBearer(auto_error=False)
|
||||||
|
),
|
||||||
|
) -> Optional[User]:
|
||||||
|
if credentials is None:
|
||||||
|
return None
|
||||||
|
token = credentials.credentials
|
||||||
|
username = verify_token(token)
|
||||||
|
if username is None:
|
||||||
|
return None
|
||||||
|
user = user_manager.get_user(username)
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
return User(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
email=user.email,
|
||||||
|
full_name=user.full_name,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at,
|
||||||
|
last_login=user.last_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
@@ -69,15 +103,13 @@ async def register(user_data: UserCreate):
|
|||||||
)
|
)
|
||||||
|
|
||||||
user_response = User(
|
user_response = User(
|
||||||
id=user["id"],
|
id=user.id,
|
||||||
username=user["username"],
|
username=user.username,
|
||||||
email=user.get("email"),
|
email=user.email,
|
||||||
full_name=user.get("full_name"),
|
full_name=user.full_name,
|
||||||
is_active=user["is_active"],
|
is_active=user.is_active,
|
||||||
created_at=datetime.fromisoformat(user["created_at"]),
|
created_at=user.created_at,
|
||||||
last_login=datetime.fromisoformat(user["last_login"])
|
last_login=user.last_login,
|
||||||
if user.get("last_login")
|
|
||||||
else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -111,23 +143,23 @@ async def login(form_data: UserLogin):
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not user.get("is_active", True):
|
if not user.is_active:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
||||||
)
|
)
|
||||||
|
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
data={"sub": user["username"]}, expires_delta=timedelta(days=7)
|
data={"sub": user.username}, expires_delta=timedelta(days=7)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
"user": {
|
"user": {
|
||||||
"id": user["id"],
|
"id": user.id,
|
||||||
"username": user["username"],
|
"username": user.username,
|
||||||
"email": user.get("email"),
|
"email": user.email,
|
||||||
"full_name": user.get("full_name"),
|
"full_name": user.full_name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +217,7 @@ async def refresh_token(refresh_request: dict):
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
||||||
)
|
)
|
||||||
if not user.get("is_active", True):
|
if not user.is_active:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Application settings routes for Ohm Stream Downloader API"""
|
"""Application settings routes for Ohm Stream Downloader API"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -8,7 +9,7 @@ from sqlmodel import Session, select
|
|||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models.auth import User, UserTable
|
from app.models.auth import User, UserTable
|
||||||
from app.models.settings import AppSettings, AppSettingsTable, AppSettingsUpdate
|
from app.models.settings import AppSettings, AppSettingsTable, AppSettingsUpdate
|
||||||
from app.routers.router_auth import get_current_user_from_token
|
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||||
from app.providers import get_anime_providers, get_series_providers
|
from app.providers import get_anime_providers, get_series_providers
|
||||||
from app.providers_manager import providers_manager
|
from app.providers_manager import providers_manager
|
||||||
|
|
||||||
@@ -19,23 +20,25 @@ templates = Jinja2Templates(directory="templates")
|
|||||||
@router.get("", response_model=AppSettings)
|
@router.get("", response_model=AppSettings)
|
||||||
async def get_settings(
|
async def get_settings(
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
session: Session = Depends(get_session)
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Get current application settings for the user"""
|
"""Get current application settings for the user"""
|
||||||
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
|
statement = select(AppSettingsTable).where(
|
||||||
|
AppSettingsTable.user_id == current_user.id
|
||||||
|
)
|
||||||
settings_obj = session.exec(statement).first()
|
settings_obj = session.exec(statement).first()
|
||||||
|
|
||||||
if not settings_obj:
|
if not settings_obj:
|
||||||
# Create default settings if they don't exist
|
# Create default settings if they don't exist
|
||||||
settings_obj = AppSettingsTable(user_id=current_user.id)
|
settings_obj = AppSettingsTable(user_id=current_user.id)
|
||||||
session.add(settings_obj)
|
session.add(settings_obj)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(settings_obj)
|
session.refresh(settings_obj)
|
||||||
|
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
default_lang=settings_obj.default_lang,
|
default_lang=settings_obj.default_lang,
|
||||||
theme=settings_obj.theme,
|
theme=settings_obj.theme,
|
||||||
disabled_providers=settings_obj.disabled_providers
|
disabled_providers=settings_obj.disabled_providers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -44,61 +47,69 @@ async def update_settings(
|
|||||||
update_data: AppSettingsUpdate,
|
update_data: AppSettingsUpdate,
|
||||||
response: Response,
|
response: Response,
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
session: Session = Depends(get_session)
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Update application settings for the user"""
|
"""Update application settings for the user"""
|
||||||
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
|
statement = select(AppSettingsTable).where(
|
||||||
|
AppSettingsTable.user_id == current_user.id
|
||||||
|
)
|
||||||
settings_obj = session.exec(statement).first()
|
settings_obj = session.exec(statement).first()
|
||||||
|
|
||||||
if not settings_obj:
|
if not settings_obj:
|
||||||
settings_obj = AppSettingsTable(user_id=current_user.id)
|
settings_obj = AppSettingsTable(user_id=current_user.id)
|
||||||
session.add(settings_obj)
|
session.add(settings_obj)
|
||||||
|
|
||||||
if update_data.default_lang is not None:
|
if update_data.default_lang is not None:
|
||||||
settings_obj.default_lang = update_data.default_lang
|
settings_obj.default_lang = update_data.default_lang
|
||||||
if update_data.theme is not None:
|
if update_data.theme is not None:
|
||||||
settings_obj.theme = update_data.theme
|
settings_obj.theme = update_data.theme
|
||||||
if update_data.disabled_providers is not None:
|
if update_data.disabled_providers is not None:
|
||||||
settings_obj.disabled_providers = update_data.disabled_providers
|
settings_obj.disabled_providers = update_data.disabled_providers
|
||||||
|
|
||||||
session.add(settings_obj)
|
session.add(settings_obj)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(settings_obj)
|
session.refresh(settings_obj)
|
||||||
|
|
||||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Paramètres enregistrés", "type": "success"}})
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
|
{"show-toast": {"message": "Paramètres enregistrés", "type": "success"}}
|
||||||
|
)
|
||||||
|
|
||||||
return settings_obj
|
return settings_obj
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers/availability")
|
@router.get("/providers/availability")
|
||||||
async def get_providers_availability(
|
async def get_providers_availability(
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
session: Session = Depends(get_session)
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Get list of providers with their availability and enabled status"""
|
"""Get list of providers with their availability and enabled status"""
|
||||||
# Get user settings
|
# Get user settings
|
||||||
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
|
statement = select(AppSettingsTable).where(
|
||||||
|
AppSettingsTable.user_id == current_user.id
|
||||||
|
)
|
||||||
settings_obj = session.exec(statement).first()
|
settings_obj = session.exec(statement).first()
|
||||||
disabled_providers = settings_obj.disabled_providers if settings_obj else []
|
disabled_providers = settings_obj.disabled_providers if settings_obj else []
|
||||||
|
|
||||||
# Get health status
|
# Get health status
|
||||||
health_status = providers_manager.get_all_status()
|
health_status = providers_manager.get_all_status()
|
||||||
|
|
||||||
# Combine anime and series providers
|
# Combine anime and series providers
|
||||||
all_providers = {**get_anime_providers(), **get_series_providers()}
|
all_providers = {**get_anime_providers(), **get_series_providers()}
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for pid, info in all_providers.items():
|
for pid, info in all_providers.items():
|
||||||
status_info = health_status.get(pid, {"status": "unknown"})
|
status_info = health_status.get(pid, {"status": "unknown"})
|
||||||
result.append({
|
result.append(
|
||||||
"id": pid,
|
{
|
||||||
"name": info["name"],
|
"id": pid,
|
||||||
"icon": info.get("icon", "🎬"),
|
"name": info["name"],
|
||||||
"status": status_info["status"],
|
"icon": info.get("icon", "🎬"),
|
||||||
"enabled": pid not in disabled_providers,
|
"status": status_info["status"],
|
||||||
"error": status_info.get("error")
|
"enabled": pid not in disabled_providers,
|
||||||
})
|
"error": status_info.get("error"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -107,16 +118,18 @@ async def toggle_provider(
|
|||||||
provider_id: str,
|
provider_id: str,
|
||||||
response: Response,
|
response: Response,
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
session: Session = Depends(get_session)
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Toggle a provider's enabled/disabled status"""
|
"""Toggle a provider's enabled/disabled status"""
|
||||||
statement = select(AppSettingsTable).where(AppSettingsTable.user_id == current_user.id)
|
statement = select(AppSettingsTable).where(
|
||||||
|
AppSettingsTable.user_id == current_user.id
|
||||||
|
)
|
||||||
settings_obj = session.exec(statement).first()
|
settings_obj = session.exec(statement).first()
|
||||||
|
|
||||||
if not settings_obj:
|
if not settings_obj:
|
||||||
settings_obj = AppSettingsTable(user_id=current_user.id)
|
settings_obj = AppSettingsTable(user_id=current_user.id)
|
||||||
session.add(settings_obj)
|
session.add(settings_obj)
|
||||||
|
|
||||||
disabled = settings_obj.disabled_providers
|
disabled = settings_obj.disabled_providers
|
||||||
if provider_id in disabled:
|
if provider_id in disabled:
|
||||||
disabled.remove(provider_id)
|
disabled.remove(provider_id)
|
||||||
@@ -124,33 +137,39 @@ async def toggle_provider(
|
|||||||
else:
|
else:
|
||||||
disabled.append(provider_id)
|
disabled.append(provider_id)
|
||||||
enabled = False
|
enabled = False
|
||||||
|
|
||||||
settings_obj.disabled_providers = disabled
|
settings_obj.disabled_providers = disabled
|
||||||
session.add(settings_obj)
|
session.add(settings_obj)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
status_text = "activé" if enabled else "désactivé"
|
status_text = "activé" if enabled else "désactivé"
|
||||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"Fournisseur {provider_id} {status_text}", "type": "success"}})
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
|
{
|
||||||
|
"show-toast": {
|
||||||
|
"message": f"Fournisseur {provider_id} {status_text}",
|
||||||
|
"type": "success",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {"id": provider_id, "enabled": enabled}
|
return {"id": provider_id, "enabled": enabled}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ui")
|
@router.get("/ui")
|
||||||
async def get_settings_ui(
|
async def get_settings_ui(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: Optional[User] = Depends(get_optional_user),
|
||||||
session: Session = Depends(get_session)
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Return the settings UI fragment for HTMX"""
|
if current_user is None:
|
||||||
# Reuse existing endpoints logic
|
return templates.TemplateResponse(
|
||||||
|
"components/login_prompt.html", {"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
settings = await get_settings(current_user, session)
|
settings = await get_settings(current_user, session)
|
||||||
providers = await get_providers_availability(current_user, session)
|
providers = await get_providers_availability(current_user, session)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"components/settings_section.html",
|
"components/settings_section.html",
|
||||||
{
|
{"request": request, "settings": settings, "providers": providers},
|
||||||
"request": request,
|
|
||||||
"settings": settings,
|
|
||||||
"providers": providers
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import re
|
|||||||
import json
|
import json
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response, Request, Query
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
BackgroundTasks,
|
||||||
|
Depends,
|
||||||
|
HTTPException,
|
||||||
|
Response,
|
||||||
|
Request,
|
||||||
|
Query,
|
||||||
|
)
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.download_manager import DownloadManager
|
from app.download_manager import DownloadManager
|
||||||
@@ -20,7 +28,7 @@ from app.models.watchlist import (
|
|||||||
WatchlistSettings,
|
WatchlistSettings,
|
||||||
WatchlistStatus,
|
WatchlistStatus,
|
||||||
)
|
)
|
||||||
from app.routers.router_auth import get_current_user_from_token
|
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
|
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@@ -28,6 +36,7 @@ templates = Jinja2Templates(directory="templates")
|
|||||||
|
|
||||||
def get_download_manager() -> DownloadManager:
|
def get_download_manager() -> DownloadManager:
|
||||||
from main import download_manager
|
from main import download_manager
|
||||||
|
|
||||||
return download_manager
|
return download_manager
|
||||||
|
|
||||||
|
|
||||||
@@ -41,38 +50,56 @@ async def add_to_watchlist(
|
|||||||
from main import watchlist_manager
|
from main import watchlist_manager
|
||||||
|
|
||||||
try:
|
try:
|
||||||
existing = watchlist_manager.get_by_anime_url(item_data.anime_url, current_user.id)
|
existing = watchlist_manager.get_by_anime_url(
|
||||||
|
item_data.anime_url, current_user.id
|
||||||
|
)
|
||||||
item = watchlist_manager.add(current_user.id, item_data)
|
item = watchlist_manager.add(current_user.id, item_data)
|
||||||
|
|
||||||
msg = f"'{item.anime_title}' ajouté à la watchlist" if not existing else f"'{item.anime_title}' est déjà dans la watchlist"
|
msg = (
|
||||||
|
f"'{item.anime_title}' ajouté à la watchlist"
|
||||||
|
if not existing
|
||||||
|
else f"'{item.anime_title}' est déjà dans la watchlist"
|
||||||
|
)
|
||||||
toast_type = "success" if not existing else "info"
|
toast_type = "success" if not existing else "info"
|
||||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": msg, "type": toast_type}})
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
|
{"show-toast": {"message": msg, "type": toast_type}}
|
||||||
|
)
|
||||||
|
|
||||||
return item
|
return item
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
logger.error(f"Error adding to watchlist: {e}", exc_info=True)
|
logger.error(f"Error adding to watchlist: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[WatchlistItem])
|
@router.get("")
|
||||||
async def get_watchlist(
|
async def get_watchlist(
|
||||||
request: Request,
|
request: Request,
|
||||||
status: Optional[WatchlistStatus] = None,
|
status: Optional[WatchlistStatus] = None,
|
||||||
html: bool = Query(False),
|
html: bool = Query(False),
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: Optional[User] = Depends(get_optional_user),
|
||||||
):
|
):
|
||||||
"""Get user's watchlist (supports HTML for HTMX)"""
|
|
||||||
from main import watchlist_manager
|
from main import watchlist_manager
|
||||||
items = watchlist_manager.get_all(user_id=current_user.id, status=status)
|
|
||||||
|
is_htmx = request.headers.get("HX-Request")
|
||||||
if html or request.headers.get("HX-Request"):
|
|
||||||
|
if current_user is None and (html or is_htmx):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"components/watchlist_items_list.html",
|
"components/login_prompt.html", {"request": request}
|
||||||
{"request": request, "items": items}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if current_user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
items = watchlist_manager.get_all(user_id=current_user.id, status=status)
|
||||||
|
|
||||||
|
if html or is_htmx:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"components/watchlist_items_list.html", {"request": request, "items": items}
|
||||||
|
)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +109,7 @@ async def get_watchlist_settings(
|
|||||||
):
|
):
|
||||||
"""Get global watchlist settings"""
|
"""Get global watchlist settings"""
|
||||||
from main import watchlist_manager
|
from main import watchlist_manager
|
||||||
|
|
||||||
return watchlist_manager.get_settings()
|
return watchlist_manager.get_settings()
|
||||||
|
|
||||||
|
|
||||||
@@ -97,9 +125,18 @@ async def update_watchlist_settings(
|
|||||||
try:
|
try:
|
||||||
updated_settings = watchlist_manager.update_settings(settings)
|
updated_settings = watchlist_manager.update_settings(settings)
|
||||||
if auto_download_scheduler.is_running():
|
if auto_download_scheduler.is_running():
|
||||||
auto_download_scheduler.update_interval(updated_settings.check_interval_hours)
|
auto_download_scheduler.update_interval(
|
||||||
|
updated_settings.check_interval_hours
|
||||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Paramètres de la watchlist mis à jour", "type": "success"}})
|
)
|
||||||
|
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
|
{
|
||||||
|
"show-toast": {
|
||||||
|
"message": "Paramètres de la watchlist mis à jour",
|
||||||
|
"type": "success",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
return updated_settings
|
return updated_settings
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -112,6 +149,7 @@ async def get_watchlist_item(
|
|||||||
):
|
):
|
||||||
"""Get a specific watchlist item"""
|
"""Get a specific watchlist item"""
|
||||||
from main import watchlist_manager
|
from main import watchlist_manager
|
||||||
|
|
||||||
item = watchlist_manager.get_by_id(item_id)
|
item = watchlist_manager.get_by_id(item_id)
|
||||||
if not item or item.user_id != current_user.id:
|
if not item or item.user_id != current_user.id:
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
@@ -133,8 +171,15 @@ async def update_watchlist_item(
|
|||||||
raise HTTPException(status_code=404, detail="Item not found")
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
updated_item = watchlist_manager.update(item_id, update_data)
|
updated_item = watchlist_manager.update(item_id, update_data)
|
||||||
|
|
||||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"'{updated_item.anime_title}' mis à jour", "type": "success"}})
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
|
{
|
||||||
|
"show-toast": {
|
||||||
|
"message": f"'{updated_item.anime_title}' mis à jour",
|
||||||
|
"type": "success",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
return updated_item
|
return updated_item
|
||||||
|
|
||||||
|
|
||||||
@@ -153,10 +198,17 @@ async def delete_from_watchlist(
|
|||||||
|
|
||||||
title = item.anime_title
|
title = item.anime_title
|
||||||
if watchlist_manager.delete(item_id):
|
if watchlist_manager.delete(item_id):
|
||||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": f"'{title}' supprimé de la watchlist", "type": "info"}})
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
|
{
|
||||||
|
"show-toast": {
|
||||||
|
"message": f"'{title}' supprimé de la watchlist",
|
||||||
|
"type": "info",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
# HTMX will handle removing the element if target is specified in the frontend
|
# HTMX will handle removing the element if target is specified in the frontend
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to delete item")
|
raise HTTPException(status_code=500, detail="Failed to delete item")
|
||||||
|
|
||||||
|
|
||||||
@@ -168,10 +220,17 @@ async def check_watchlist_now(
|
|||||||
):
|
):
|
||||||
"""Trigger an immediate check for new episodes"""
|
"""Trigger an immediate check for new episodes"""
|
||||||
from main import auto_download_scheduler
|
from main import auto_download_scheduler
|
||||||
|
|
||||||
background_tasks.add_task(auto_download_scheduler.trigger_check_now)
|
background_tasks.add_task(auto_download_scheduler.trigger_check_now)
|
||||||
response.headers["HX-Trigger"] = json.dumps({"show-toast": {"message": "Vérification de la watchlist lancée en arrière-plan", "type": "info"}})
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
|
{
|
||||||
|
"show-toast": {
|
||||||
|
"message": "Vérification de la watchlist lancée en arrière-plan",
|
||||||
|
"type": "info",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {"status": "success", "message": "Check triggered"}
|
return {"status": "success", "message": "Check triggered"}
|
||||||
|
|
||||||
|
|
||||||
@@ -181,4 +240,5 @@ async def get_watchlist_stats(
|
|||||||
):
|
):
|
||||||
"""Get watchlist statistics for the user"""
|
"""Get watchlist statistics for the user"""
|
||||||
from main import watchlist_manager
|
from main import watchlist_manager
|
||||||
|
|
||||||
return watchlist_manager.get_stats(current_user.id)
|
return watchlist_manager.get_stats(current_user.id)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -21,10 +21,18 @@ from app.models import DownloadTask, DownloadStatus
|
|||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PERMISSIONS_POLICY_VALUE = (
|
||||||
|
"accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), "
|
||||||
|
"camera=(), display-capture=(), document-domain=(), encrypted-media=(), "
|
||||||
|
"fullscreen=*, gamepad=(), geolocation=(), gyroscope=(), "
|
||||||
|
"magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=*, "
|
||||||
|
"publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), "
|
||||||
|
"usb=(), web-share=(), xr-spatial-tracking=()"
|
||||||
|
)
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
app = FastAPI(title="Ohm Stream Downloader")
|
app = FastAPI(title="Ohm Stream Downloader")
|
||||||
|
|
||||||
# Configure CORS
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[
|
allow_origins=[
|
||||||
@@ -40,6 +48,14 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def permissions_policy_middleware(request: Request, call_next):
|
||||||
|
response: Response = await call_next(request)
|
||||||
|
response.headers["Permissions-Policy"] = PERMISSIONS_POLICY_VALUE
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# Initialize download manager
|
# Initialize download manager
|
||||||
download_manager = DownloadManager(download_dir="downloads", max_parallel=3)
|
download_manager = DownloadManager(download_dir="downloads", max_parallel=3)
|
||||||
|
|
||||||
@@ -54,6 +70,7 @@ async def startup_event():
|
|||||||
"""Initialize services on application startup"""
|
"""Initialize services on application startup"""
|
||||||
# Create database tables if they don't exist
|
# Create database tables if they don't exist
|
||||||
from app.database import create_db_and_tables
|
from app.database import create_db_and_tables
|
||||||
|
|
||||||
create_db_and_tables()
|
create_db_and_tables()
|
||||||
logger.info("Database tables initialized")
|
logger.info("Database tables initialized")
|
||||||
|
|
||||||
|
|||||||
+58
-98
@@ -170,140 +170,106 @@ h1 {
|
|||||||
.btn-watch { background: var(--primary); color: #000; }
|
.btn-watch { background: var(--primary); color: #000; }
|
||||||
.btn-download { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; }
|
.btn-download { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; }
|
||||||
|
|
||||||
/* Horizontal Rows (Netflix Style) */
|
/* Horizontal Scroll Row (Homepage) */
|
||||||
.streaming-row {
|
.home-row, .streaming-row, .recommendations-carousel, .releases-carousel {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 20px 5px;
|
padding: 10px 0 20px;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
.home-row::-webkit-scrollbar, .streaming-row::-webkit-scrollbar, .recommendations-carousel::-webkit-scrollbar, .releases-carousel::-webkit-scrollbar {
|
||||||
.streaming-row::-webkit-scrollbar {
|
height: 4px;
|
||||||
height: 6px;
|
|
||||||
}
|
}
|
||||||
|
.home-row::-webkit-scrollbar-thumb, .streaming-row::-webkit-scrollbar-thumb, .recommendations-carousel::-webkit-scrollbar-thumb, .releases-carousel::-webkit-scrollbar-thumb {
|
||||||
.streaming-row::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
.home-row::-webkit-scrollbar-thumb:hover, .streaming-row::-webkit-scrollbar-thumb:hover, .recommendations-carousel::-webkit-scrollbar-thumb:hover, .releases-carousel::-webkit-scrollbar-thumb:hover {
|
||||||
.streaming-row::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modern Card Design */
|
/* Home Card */
|
||||||
.anime-card {
|
.hc {
|
||||||
flex: 0 0 220px;
|
flex: 0 0 180px;
|
||||||
|
display: block;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: var(--card-radius);
|
border-radius: var(--card-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
position: relative;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
display: flex;
|
text-decoration: none;
|
||||||
flex-direction: column;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
.hc:hover {
|
||||||
.anime-card:hover {
|
transform: scale(1.08);
|
||||||
transform: scale(1.05);
|
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
.hc-poster {
|
||||||
.anime-poster {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-top: 150%;
|
padding-top: 150%;
|
||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
.hc-poster img {
|
||||||
.anime-poster img {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; width: 100%; height: 100%;
|
top: 0; left: 0; width: 100%; height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
transition: var(--transition);
|
|
||||||
}
|
}
|
||||||
|
.hc-rating {
|
||||||
.anime-rating-badge {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px; right: 10px;
|
top: 8px; right: 8px;
|
||||||
background: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.85);
|
||||||
color: #ffcc00;
|
color: #ffcc00;
|
||||||
padding: 4px 8px;
|
padding: 2px 7px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
backdrop-filter: blur(5px);
|
|
||||||
border: 1px solid rgba(255, 204, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
|
.hc-play {
|
||||||
.anime-overlay {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; width: 100%; height: 100%;
|
bottom: 8px; right: 8px;
|
||||||
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0) 100%);
|
width: 32px; height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--bg-dark);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: center;
|
||||||
padding: 15px;
|
font-size: 0.75rem;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
.hc:hover .hc-play { opacity: 1; }
|
||||||
.anime-card:hover .anime-overlay { opacity: 1; }
|
.hc-info {
|
||||||
|
padding: 10px;
|
||||||
.overlay-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
.hc-src {
|
||||||
/* Info Area */
|
font-size: 0.6rem;
|
||||||
.anime-info {
|
font-weight: 700;
|
||||||
padding: 12px;
|
text-transform: uppercase;
|
||||||
flex-grow: 1;
|
color: var(--primary);
|
||||||
display: flex;
|
letter-spacing: 0.5px;
|
||||||
flex-direction: column;
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
.hc-title {
|
||||||
.anime-title {
|
font-size: 0.82rem;
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 8px;
|
display: block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.anime-meta-tags {
|
/* Responsive Grid for Search */
|
||||||
display: flex;
|
.anime-grid {
|
||||||
gap: 5px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action Buttons Grid */
|
|
||||||
.anime-card-buttons {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
gap: 8px;
|
gap: 20px;
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-add-watchlist.followed {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
background: rgba(0, 255, 136, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tabs UI */
|
/* Tabs UI */
|
||||||
@@ -640,23 +606,17 @@ h1 {
|
|||||||
opacity: 0.15;
|
opacity: 0.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.htmx-indicator { display: none; }
|
||||||
|
.htmx-indicator.htmx-request { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
/* Section Containers */
|
/* Section Containers */
|
||||||
.section-container {
|
.section-container {
|
||||||
margin-bottom: 50px;
|
margin-bottom: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Grid for Search */
|
|
||||||
.anime-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.anime-card { flex: 0 0 160px; }
|
.anime-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 12px; }
|
||||||
.anime-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; }
|
.hc { flex: 0 0 140px; }
|
||||||
.btn-card span { display: none; }
|
|
||||||
.btn-card { padding: 10px; }
|
|
||||||
.tabs { gap: 10px; }
|
.tabs { gap: 10px; }
|
||||||
.auth-panel { flex-direction: column; gap: 15px; text-align: center; }
|
.auth-panel { flex-direction: column; gap: 15px; text-align: center; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -41,11 +41,10 @@ function removeToken() {
|
|||||||
|
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
console.log('Checking authentication...');
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.log('No token found');
|
window.dispatchEvent(new CustomEvent('auth-logout'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,20 +55,18 @@ async function checkAuth() {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('Auth success:', data.user.username);
|
|
||||||
|
|
||||||
// Dispatch for Alpine
|
|
||||||
window.dispatchEvent(new CustomEvent('auth-success', {
|
window.dispatchEvent(new CustomEvent('auth-success', {
|
||||||
detail: { username: data.user.full_name || data.user.username }
|
detail: { username: data.user.full_name || data.user.username }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
console.log('Token invalid');
|
removeToken();
|
||||||
|
window.dispatchEvent(new CustomEvent('auth-logout'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth check error:', error);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
async function loadDownloads() {
|
async function loadDownloads() {
|
||||||
console.log('Legacy loadDownloads called - redirected to HTMX refresh');
|
|
||||||
if (typeof htmx !== 'undefined') {
|
if (typeof htmx !== 'undefined') {
|
||||||
htmx.trigger('#downloads-container-inner', 'refresh');
|
htmx.trigger('#downloads-container-inner', 'refresh');
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -18,6 +18,16 @@
|
|||||||
[x-cloak] { display: none !important; }
|
[x-cloak] { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Configure HTMX to include auth token in all requests -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('htmx:configRequest', (event) => {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (token) {
|
||||||
|
event.detail.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Legacy JavaScript (Refactored to HTMX/Alpine) -->
|
<!-- Legacy JavaScript (Refactored to HTMX/Alpine) -->
|
||||||
<script src="/static/js/auth.js?v=1.10" defer></script>
|
<script src="/static/js/auth.js?v=1.10" defer></script>
|
||||||
<script src="/static/js/api.js?v=1.11" defer></script>
|
<script src="/static/js/api.js?v=1.11" defer></script>
|
||||||
@@ -46,14 +56,15 @@
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
username: '',
|
username: '',
|
||||||
init() {
|
init() {
|
||||||
console.log('Global app state ready');
|
|
||||||
window.addEventListener('auth-success', (e) => {
|
window.addEventListener('auth-success', (e) => {
|
||||||
console.log('Alpine auth-success received');
|
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
this.username = e.detail.username;
|
this.username = e.detail.username;
|
||||||
});
|
});
|
||||||
|
window.addEventListener('auth-logout', () => {
|
||||||
|
this.isAuthenticated = false;
|
||||||
|
this.username = '';
|
||||||
|
});
|
||||||
window.addEventListener('set-tab', (e) => {
|
window.addEventListener('set-tab', (e) => {
|
||||||
console.log('Alpine set-tab received:', e.detail.tab);
|
|
||||||
this.activeTab = e.detail.tab;
|
this.activeTab = e.detail.tab;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,18 @@
|
|||||||
{% macro anime_card(anime, in_watchlist=False, lang='vostfr') %}
|
{% macro anime_card(anime, in_watchlist=False, lang='vostfr') %}
|
||||||
<div class="anime-card" id="anime-{{ anime.url | hash }}">
|
<div class="hc" id="anime-{{ anime.url | hash }}"
|
||||||
<div class="anime-poster">
|
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } })); $nextTick(() => { const input = document.getElementById('animeSearchInput'); if (input) { input.value = '{{ anime.title | e }}'; htmx.trigger(input, 'keyup'); } });">
|
||||||
|
<div class="hc-poster">
|
||||||
{% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/161625/00d9ff?text=No+Image' %}
|
{% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/161625/00d9ff?text=No+Image' %}
|
||||||
<img src="{{ poster }}"
|
<img src="{{ poster }}" alt="{{ anime.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||||
alt="{{ anime.title }}"
|
onerror="this.src='https://placehold.co/400x600/161625/00d9ff?text=Error'; this.onerror=null;">
|
||||||
loading="lazy"
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
onerror="this.src='https://placehold.co/400x600/161625/00d9ff?text=Image+Error'; this.onerror=null;">
|
|
||||||
|
|
||||||
{% if anime.metadata and anime.metadata.rating %}
|
{% if anime.metadata and anime.metadata.rating %}
|
||||||
<div class="anime-rating-badge">
|
<span class="hc-rating"><i class="fas fa-star"></i> {{ anime.metadata.rating }}</span>
|
||||||
<i class="fas fa-star"></i> {{ anime.metadata.rating }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<span class="hc-play"><i class="fas fa-search"></i></span>
|
||||||
<div class="anime-overlay">
|
|
||||||
<div class="overlay-buttons">
|
|
||||||
<button class="btn btn-primary btn-circle"
|
|
||||||
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}&lang={{ lang }}"
|
|
||||||
hx-target="#player-container"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
title="Play">
|
|
||||||
<i class="fas fa-play"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hc-info">
|
||||||
<div class="anime-info">
|
<span class="hc-src">{{ anime.provider_id or 'Anime' }}</span>
|
||||||
<h3 class="anime-title" title="{{ anime.title }}">{{ anime.title }}</h3>
|
<span class="hc-title">{{ anime.title }}</span>
|
||||||
|
|
||||||
<div class="anime-meta-tags">
|
|
||||||
<span class="badge">{{ anime.provider_id or 'Anime' }}</span>
|
|
||||||
<span class="badge" style="color: var(--primary)">{{ lang | upper }}</span>
|
|
||||||
{% if anime.metadata and anime.metadata.status %}
|
|
||||||
<span class="badge" style="color: #aaa">{{ anime.metadata.status }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="anime-card-buttons">
|
|
||||||
<button class="btn btn-primary btn-small"
|
|
||||||
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}&lang={{ lang }}"
|
|
||||||
hx-target="#player-container"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<i class="fas fa-eye"></i> <span>Regarder</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-small"
|
|
||||||
hx-get="/api/anime/episodes?url={{ anime.url | urlencode }}&lang={{ lang }}"
|
|
||||||
hx-target="#player-container"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<i class="fas fa-download"></i> <span>Télécharger</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if not in_watchlist %}
|
|
||||||
<button class="btn btn-secondary btn-small btn-block"
|
|
||||||
hx-post="/api/watchlist"
|
|
||||||
hx-vals='{"anime_url": "{{ anime.url }}", "anime_title": "{{ anime.title }}", "provider_id": "{{ anime.provider_id }}", "lang": "{{ lang }}"}'
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="this.innerHTML='<i class=\'fas fa-check\'></i> Suivi'; this.disabled=true; this.classList.add('followed')">
|
|
||||||
<i class="fas fa-plus"></i> Watchlist
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button class="btn btn-secondary btn-small btn-block followed" disabled>
|
|
||||||
<i class="fas fa-check"></i> Suivi
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|||||||
@@ -1,38 +1,156 @@
|
|||||||
{% from "components/anime_card.html" import anime_card %}
|
{% set accent = "#00d9ff" %}
|
||||||
|
{% set default_lang = settings.default_lang if settings else 'vostfr' %}
|
||||||
|
|
||||||
<div class="search-results-container">
|
{% set _groups = namespace(items={}) %}
|
||||||
{% if results %}
|
{% for pid, items in (results or {}).items() %}
|
||||||
{% for provider_id, items in results.items() %}
|
{% for item in items %}
|
||||||
<div class="provider-section">
|
{% set _key = item.title | lower | trim %}
|
||||||
<h3 class="provider-title">{{ provider_id | upper }}</h3>
|
{% if _key not in _groups.items %}
|
||||||
<div class="anime-grid">
|
{% set _ = _groups.items.update({_key: {
|
||||||
{% for anime in items %}
|
"title": item.title,
|
||||||
{{ anime_card(anime, lang=settings.default_lang if settings else 'vostfr') }}
|
"cover": item.cover_image or (item.metadata.poster_image if item.metadata else "") or "",
|
||||||
{% endfor %}
|
"synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else "")[:300],
|
||||||
|
"rating": item.metadata.rating if item.metadata and item.metadata.rating else "",
|
||||||
|
"genres": item.metadata.genres if item.metadata and item.metadata.genres else [],
|
||||||
|
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
|
||||||
|
}}) %}
|
||||||
|
{% else %}
|
||||||
|
{% set _existing = _groups.items[_key] %}
|
||||||
|
{% if not _existing.cover and item.cover_image %}
|
||||||
|
{% set _ = _existing.update({"cover": item.cover_image}) %}
|
||||||
|
{% endif %}
|
||||||
|
{% if not _existing.synopsis and item.metadata and item.metadata.synopsis %}
|
||||||
|
{% set _ = _existing.update({"synopsis": item.metadata.synopsis[:300]}) %}
|
||||||
|
{% endif %}
|
||||||
|
{% if not _existing.rating and item.metadata and item.metadata.rating %}
|
||||||
|
{% set _ = _existing.update({"rating": item.metadata.rating}) %}
|
||||||
|
{% endif %}
|
||||||
|
{% set _ = _existing["providers"].append({"id": item.provider_id or pid, "url": item.url}) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="sr-list" x-data="{ openDropdown: null }">
|
||||||
|
{% if _groups.items.values() | list | length > 0 %}
|
||||||
|
{% for group in _groups.items.values() | list %}
|
||||||
|
{% set first_url = group.providers[0].url %}
|
||||||
|
<div class="sr-card" style="--sr-accent: {{ accent }};">
|
||||||
|
<a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener">
|
||||||
|
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/161625/00d9ff?text=No+Image' }}"
|
||||||
|
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||||
|
onerror="this.src='https://placehold.co/240x360/161625/00d9ff?text=Error'; this.onerror=null;">
|
||||||
|
</a>
|
||||||
|
<div class="sr-body">
|
||||||
|
<div class="sr-top">
|
||||||
|
<h3 class="sr-title">{{ group.title }}</h3>
|
||||||
|
{% if group.rating %}
|
||||||
|
<span class="sr-rating"><i class="fas fa-star"></i> {{ group.rating }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if group.synopsis %}
|
||||||
|
<p class="sr-synopsis">{{ group.synopsis[:200] }}{% if group.synopsis | length > 200 %}...{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if group.genres %}
|
||||||
|
<div class="sr-tags">
|
||||||
|
{% for g in group.genres[:5] %}
|
||||||
|
<span class="sr-tag">{{ g }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="sr-providers">
|
||||||
|
{% for p in group.providers %}
|
||||||
|
<a class="sr-provider-badge" href="{{ p.url }}" target="_blank" rel="noopener">{{ p.id | upper }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sr-actions">
|
||||||
|
<a class="sr-btn sr-btn-watch" href="{{ first_url }}" target="_blank" rel="noopener">
|
||||||
|
<i class="fas fa-play"></i> Regarder
|
||||||
|
</a>
|
||||||
|
<div class="sr-dropdown" @click.outside="openDropdown = null">
|
||||||
|
<button class="sr-btn sr-btn-dl" @click="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
|
||||||
|
<i class="fas fa-download"></i> Telecharger <i class="fas fa-chevron-down" style="font-size:0.6rem;margin-left:4px;"></i>
|
||||||
|
</button>
|
||||||
|
<div class="sr-dropdown-menu" x-show="openDropdown === '{{ first_url | urlencode }}'" x-transition>
|
||||||
|
<button class="sr-dropdown-item"
|
||||||
|
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="openDropdown = null">
|
||||||
|
<i class="fas fa-layer-group"></i> Saison complete
|
||||||
|
</button>
|
||||||
|
<button class="sr-dropdown-item"
|
||||||
|
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
||||||
|
hx-target="#dl-episodes-{{ loop.index }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="openDropdown = null">
|
||||||
|
<i class="fas fa-list-ol"></i> Choisir des episodes
|
||||||
|
</button>
|
||||||
|
<div id="dl-episodes-{{ loop.index }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="sr-btn sr-btn-follow"
|
||||||
|
hx-post="/api/watchlist"
|
||||||
|
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
|
||||||
|
<i class="fas fa-plus"></i> Suivre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="no-results">
|
<div class="sr-empty">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
<p>Aucun anime trouvé pour votre recherche.</p>
|
<p>Aucun anime trouve pour votre recherche.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.provider-section { margin-bottom: 40px; }
|
.sr-list { display: flex; flex-direction: column; gap: 16px; }
|
||||||
.provider-title {
|
.sr-card {
|
||||||
color: var(--primary);
|
display: flex; gap: 20px;
|
||||||
margin-bottom: 20px;
|
background: var(--bg-card); border-radius: var(--card-radius);
|
||||||
font-size: 1.2rem;
|
padding: 20px; border: 1px solid rgba(255,255,255,0.05);
|
||||||
text-transform: uppercase;
|
transition: var(--transition);
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
}
|
||||||
.no-results {
|
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
|
||||||
text-align: center;
|
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
|
||||||
padding: 100px 20px;
|
.sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
color: var(--text-dim);
|
.sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.sr-top { display: flex; align-items: baseline; gap: 12px; }
|
||||||
|
.sr-title { font-size: 1.1rem; font-weight: 700; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.sr-rating { flex-shrink: 0; font-size: 0.8rem; font-weight: 700; color: #ffcc00; }
|
||||||
|
.sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.sr-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; }
|
||||||
|
.sr-tag { font-size: 0.65rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-dim); }
|
||||||
|
.sr-providers { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; }
|
||||||
|
.sr-provider-badge:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||||
|
.sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||||
|
.sr-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); }
|
||||||
|
.sr-btn:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); }
|
||||||
|
.sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); }
|
||||||
|
.sr-btn-watch:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||||
|
.sr-btn-follow { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.sr-btn-follow:hover { background: var(--accent); color: var(--bg-dark); }
|
||||||
|
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; }
|
||||||
|
.sr-dropdown { position: relative; }
|
||||||
|
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 4px; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
|
||||||
|
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 6px; transition: var(--transition); text-align: left; }
|
||||||
|
.sr-dropdown-item:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
.sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); }
|
||||||
|
.sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sr-card { flex-direction: column; align-items: center; text-align: center; gap: 12px; padding: 16px; }
|
||||||
|
.sr-poster-link { width: 160px; }
|
||||||
|
.sr-top { justify-content: center; }
|
||||||
|
.sr-tags { justify-content: center; }
|
||||||
|
.sr-providers { justify-content: center; }
|
||||||
|
.sr-actions { justify-content: center; }
|
||||||
}
|
}
|
||||||
.no-results i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
<span style="color: #fff; font-size: 14px;">Connecté en tant que <strong x-text="username" style="color: var(--primary);">-</strong></span>
|
<span style="color: #fff; font-size: 14px;">Connecté en tant que <strong x-text="username" style="color: var(--primary);">-</strong></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary btn-small"
|
<button class="btn btn-secondary btn-small"
|
||||||
|
onclick="removeToken(); isAuthenticated = false"
|
||||||
hx-post="/api/auth/logout"
|
hx-post="/api/auth/logout"
|
||||||
hx-on::after-request="isAuthenticated = false; window.location.reload()">
|
hx-on::after-request="window.location.href = '/login'">
|
||||||
🚪 Déconnexion
|
🚪 Déconnexion
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<!-- Home Section: Premium Layout -->
|
|
||||||
<div id="tab-home" class="tab-content" x-show="activeTab === 'home'">
|
<div id="tab-home" class="tab-content" x-show="activeTab === 'home'">
|
||||||
|
|
||||||
<!-- Hero / Featured area could go here later -->
|
|
||||||
|
|
||||||
<!-- Recommendations Row -->
|
|
||||||
<div class="section-container">
|
<div class="section-container">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>🎯 Recommandé pour vous</h2>
|
<h2>🎯 Recommandé pour vous</h2>
|
||||||
@@ -16,12 +12,11 @@
|
|||||||
<div id="recommendationsList"
|
<div id="recommendationsList"
|
||||||
hx-get="/api/recommendations"
|
hx-get="/api/recommendations"
|
||||||
hx-trigger="load delay:100ms"
|
hx-trigger="load delay:100ms"
|
||||||
class="streaming-row">
|
class="home-row">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-placeholder"><div class="spinner"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Latest Releases Row -->
|
|
||||||
<div class="section-container">
|
<div class="section-container">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>🔥 Dernières sorties</h2>
|
<h2>🔥 Dernières sorties</h2>
|
||||||
@@ -34,8 +29,8 @@
|
|||||||
<div id="releasesList"
|
<div id="releasesList"
|
||||||
hx-get="/api/releases/latest"
|
hx-get="/api/releases/latest"
|
||||||
hx-trigger="load delay:300ms"
|
hx-trigger="load delay:300ms"
|
||||||
class="streaming-row">
|
class="home-row">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-placeholder"><div class="spinner"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,58 +1,18 @@
|
|||||||
{% macro series_card(series, in_watchlist=False, lang='vf') %}
|
{% macro series_card(series, in_watchlist=False, lang='vf') %}
|
||||||
<div class="anime-card" id="series-{{ series.url | hash }}">
|
<div class="ac" id="series-{{ series.url | hash }}">
|
||||||
<div class="anime-poster">
|
<div class="ac-poster">
|
||||||
<img src="{{ series.cover_image or 'https://placehold.co/400x600/161625/ff6b6b?text=No+Image' }}"
|
<img src="{{ series.cover_image or 'https://placehold.co/400x600/161625/ff6b6b?text=No+Image' }}"
|
||||||
alt="{{ series.title }}"
|
alt="{{ series.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||||
loading="lazy"
|
onerror="this.src='https://placehold.co/400x600/161625/ff6b6b?text=Error'; this.onerror=null;">
|
||||||
referrerpolicy="no-referrer"
|
<button class="ac-play"
|
||||||
onerror="this.src='https://placehold.co/400x600/161625/ff6b6b?text=Image+Error'; this.onerror=null;">
|
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
|
||||||
<div class="anime-overlay">
|
hx-target="#player-container" hx-swap="innerHTML">
|
||||||
<div class="overlay-buttons">
|
<i class="fas fa-play"></i>
|
||||||
<button class="btn btn-primary btn-circle"
|
</button>
|
||||||
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
|
|
||||||
hx-target="#player-container"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
title="Play">
|
|
||||||
<i class="fas fa-play"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="anime-info">
|
<div class="ac-info">
|
||||||
<h3 class="anime-title" title="{{ series.title }}">{{ series.title }}</h3>
|
<span class="ac-provider" style="--ac-color: var(--secondary)">{{ series.provider_id | upper if series.provider_id else 'FS7' }}</span>
|
||||||
<div class="anime-meta-tags">
|
<h3 class="ac-title" title="{{ series.title }}">{{ series.title }}</h3>
|
||||||
<span class="badge">{{ series.provider_id | upper if series.provider_id else 'FS7' }}</span>
|
|
||||||
<span class="badge" style="color: var(--primary)">{{ lang | upper }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="anime-card-buttons">
|
|
||||||
<button class="btn btn-primary btn-small"
|
|
||||||
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
|
|
||||||
hx-target="#player-container"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<i class="fas fa-eye"></i> <span>Regarder</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-small"
|
|
||||||
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
|
|
||||||
hx-target="#player-container"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<i class="fas fa-download"></i> <span>Télécharger</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if not in_watchlist %}
|
|
||||||
<button class="btn btn-secondary btn-small btn-block"
|
|
||||||
hx-post="/api/watchlist"
|
|
||||||
hx-vals='{"anime_url": "{{ series.url }}", "anime_title": "{{ series.title }}", "provider_id": "{{ series.provider_id or 'fs7' }}", "lang": "{{ lang }}"}'
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="this.innerHTML='<i class=\'fas fa-check\'></i> Suivi'; this.disabled=true; this.classList.add('followed')">
|
|
||||||
<i class="fas fa-plus"></i> Watchlist
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button class="btn btn-secondary btn-small btn-block followed" disabled>
|
|
||||||
<i class="fas fa-check"></i> Suivi
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|||||||
@@ -1,38 +1,130 @@
|
|||||||
{% from "components/series_card.html" import series_card %}
|
{% set accent = "#ff6b6b" %}
|
||||||
|
{% set default_lang = settings.default_lang if settings else 'vf' %}
|
||||||
|
|
||||||
<div class="search-results-container">
|
{% set _groups = namespace(items={}) %}
|
||||||
{% if results %}
|
{% for pid, items in (results or {}).items() %}
|
||||||
{% for provider_id, items in results.items() %}
|
{% for item in items %}
|
||||||
<div class="provider-section">
|
{% set _key = item.title | lower | trim %}
|
||||||
<h3 class="provider-title">{{ provider_id | upper }}</h3>
|
{% if _key not in _groups.items %}
|
||||||
<div class="anime-grid">
|
{% set _ = _groups.items.update({_key: {
|
||||||
{% for series in items %}
|
"title": item.title,
|
||||||
{{ series_card(series, lang=settings.default_lang if settings else 'vf') }}
|
"cover": item.cover_image or "",
|
||||||
{% endfor %}
|
"synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else "")[:300],
|
||||||
|
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
|
||||||
|
}}) %}
|
||||||
|
{% else %}
|
||||||
|
{% set _existing = _groups.items[_key] %}
|
||||||
|
{% if not _existing.cover and item.cover_image %}
|
||||||
|
{% set _ = _existing.update({"cover": item.cover_image}) %}
|
||||||
|
{% endif %}
|
||||||
|
{% set _ = _existing["providers"].append({"id": item.provider_id or pid, "url": item.url}) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="sr-list" x-data="{ openDropdown: null }">
|
||||||
|
{% if _groups.items.values() | list | length > 0 %}
|
||||||
|
{% for group in _groups.items.values() | list %}
|
||||||
|
{% set first_url = group.providers[0].url %}
|
||||||
|
<div class="sr-card" style="--sr-accent: {{ accent }};">
|
||||||
|
<a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener">
|
||||||
|
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/161625/ff6b6b?text=No+Image' }}"
|
||||||
|
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||||
|
onerror="this.src='https://placehold.co/240x360/161625/ff6b6b?text=Error'; this.onerror=null;">
|
||||||
|
</a>
|
||||||
|
<div class="sr-body">
|
||||||
|
<h3 class="sr-title">{{ group.title }}</h3>
|
||||||
|
|
||||||
|
{% if group.synopsis %}
|
||||||
|
<p class="sr-synopsis">{{ group.synopsis[:200] }}{% if group.synopsis | length > 200 %}...{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="sr-providers">
|
||||||
|
{% for p in group.providers %}
|
||||||
|
<a class="sr-provider-badge" href="{{ p.url }}" target="_blank" rel="noopener">{{ p.id | upper }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sr-actions">
|
||||||
|
<a class="sr-btn sr-btn-watch" href="{{ first_url }}" target="_blank" rel="noopener">
|
||||||
|
<i class="fas fa-play"></i> Regarder
|
||||||
|
</a>
|
||||||
|
<div class="sr-dropdown" @click.outside="openDropdown = null">
|
||||||
|
<button class="sr-btn sr-btn-dl" @click="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
|
||||||
|
<i class="fas fa-download"></i> Telecharger <i class="fas fa-chevron-down" style="font-size:0.6rem;margin-left:4px;"></i>
|
||||||
|
</button>
|
||||||
|
<div class="sr-dropdown-menu" x-show="openDropdown === '{{ first_url | urlencode }}'" x-transition>
|
||||||
|
<button class="sr-dropdown-item"
|
||||||
|
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="openDropdown = null">
|
||||||
|
<i class="fas fa-layer-group"></i> Saison complete
|
||||||
|
</button>
|
||||||
|
<button class="sr-dropdown-item"
|
||||||
|
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
||||||
|
hx-target="#dl-episodes-{{ loop.index }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="openDropdown = null">
|
||||||
|
<i class="fas fa-list-ol"></i> Choisir des episodes
|
||||||
|
</button>
|
||||||
|
<div id="dl-episodes-{{ loop.index }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="sr-btn sr-btn-follow"
|
||||||
|
hx-post="/api/watchlist"
|
||||||
|
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
|
||||||
|
<i class="fas fa-plus"></i> Suivre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="no-results">
|
<div class="sr-empty">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
<p>Aucune série TV trouvée pour votre recherche.</p>
|
<p>Aucune serie TV trouvee pour votre recherche.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.provider-section { margin-bottom: 40px; }
|
.sr-list { display: flex; flex-direction: column; gap: 16px; }
|
||||||
.provider-title {
|
.sr-card {
|
||||||
color: var(--secondary);
|
display: flex; gap: 20px;
|
||||||
margin-bottom: 20px;
|
background: var(--bg-card); border-radius: var(--card-radius);
|
||||||
font-size: 1.2rem;
|
padding: 20px; border: 1px solid rgba(255,255,255,0.05);
|
||||||
text-transform: uppercase;
|
transition: var(--transition);
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
}
|
||||||
.no-results {
|
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
|
||||||
text-align: center;
|
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
|
||||||
padding: 100px 20px;
|
.sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
color: var(--text-dim);
|
.sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.sr-title { font-size: 1.1rem; font-weight: 700; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.sr-providers { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; }
|
||||||
|
.sr-provider-badge:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||||
|
.sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||||
|
.sr-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); }
|
||||||
|
.sr-btn:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); }
|
||||||
|
.sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); }
|
||||||
|
.sr-btn-watch:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||||
|
.sr-btn-follow { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.sr-btn-follow:hover { background: var(--accent); color: var(--bg-dark); }
|
||||||
|
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; }
|
||||||
|
.sr-dropdown { position: relative; }
|
||||||
|
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 4px; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
|
||||||
|
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 6px; transition: var(--transition); text-align: left; }
|
||||||
|
.sr-dropdown-item:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
.sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); }
|
||||||
|
.sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sr-card { flex-direction: column; align-items: center; text-align: center; gap: 12px; padding: 16px; }
|
||||||
|
.sr-poster-link { width: 160px; }
|
||||||
|
.sr-title { white-space: normal; text-overflow: initial; }
|
||||||
|
.sr-providers { justify-content: center; }
|
||||||
|
.sr-actions { justify-content: center; }
|
||||||
}
|
}
|
||||||
.no-results i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
Rechercher
|
Rechercher
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--primary); display: none;">
|
<div id="search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--primary);">
|
||||||
<div class="spinner"></div> Recherche en cours...
|
<div class="spinner"></div> Recherche en cours...
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 15px; padding: 12px; background: rgba(0, 217, 255, 0.05); border: 1px solid rgba(0, 217, 255, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
|
<div style="margin-top: 15px; padding: 12px; background: rgba(0, 217, 255, 0.05); border: 1px solid rgba(0, 217, 255, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
Rechercher
|
Rechercher
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="series-search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--secondary); display: none;">
|
<div id="series-search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--secondary);">
|
||||||
<div class="spinner"></div> Recherche en cours...
|
<div class="spinner"></div> Recherche en cours...
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 15px; padding: 12px; background: rgba(255, 107, 107, 0.05); border: 1px solid rgba(255, 107, 107, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
|
<div style="margin-top: 15px; padding: 12px; background: rgba(255, 107, 107, 0.05); border: 1px solid rgba(255, 107, 107, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
|
||||||
|
|||||||
+33
-41
@@ -1,38 +1,35 @@
|
|||||||
# AGENTS.md - Test Suite
|
# AGENTS.md - Test Suite
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
|
Pytest suite: 18+ test files, 5000+ lines. Auto-marked (unit/integration/asyncio) + manual markers (slow/network).
|
||||||
Pytest test suite for Ohm Stream Downloader with 18 test files covering unit and integration tests.
|
|
||||||
|
|
||||||
## STRUCTURE
|
## STRUCTURE
|
||||||
|
|
||||||
```
|
```
|
||||||
tests/
|
tests/
|
||||||
├── conftest.py # Fixtures & pytest config
|
├── conftest.py # 301 lines: fixtures, markers, DB isolation, event loop
|
||||||
├── test_*.py # 18 test modules
|
├── e2e/ # Playwright end-to-end tests
|
||||||
├── test_api.py # FastAPI endpoints (integration)
|
├── test_api.py # 627 lines — FastAPI endpoints (auto integration)
|
||||||
├── test_auth.py # JWT authentication
|
├── test_favorites.py # 564 lines — Favorites CRUD
|
||||||
├── test_download_manager.py # Download queue management
|
├── test_sonarr.py # 513 lines — Sonarr webhook
|
||||||
├── test_downloaders.py # Provider downloaders
|
├── test_metadata_enrichment.py # 442 lines — Kitsu API
|
||||||
├── test_anime_sama_*.py # Anime-Sama provider variants
|
├── test_download_manager.py # 395 lines — Download queue
|
||||||
├── test_favorites.py # Favorites management
|
├── test_downloaders.py # 341 lines — Provider scrapers
|
||||||
├── test_french_manga.py # French-Manga provider
|
├── test_models.py # 321 lines — Pydantic models
|
||||||
├── test_models.py # Pydantic model validation
|
├── test_utils.py # 246 lines — sanitize_filename, is_safe_filename
|
||||||
├── test_sonarr.py # Sonarr webhook integration
|
└── test_watchlist.py # 178 lines — Auto-download watchlist
|
||||||
├── test_utils.py # Utility functions
|
|
||||||
├── test_watchlist.py # Auto-download watchlist
|
|
||||||
├── test_metadata_enrichment.py
|
|
||||||
├── test_translate_api.py
|
|
||||||
├── test_delete_and_restore.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Root-level tests** (legacy placement, NOT in tests/):
|
||||||
|
- `test_watchlist_simple.py`, `test_watchlist_e2e.py` — should be moved to tests/
|
||||||
|
|
||||||
## WHERE TO LOOK
|
## WHERE TO LOOK
|
||||||
|
|
||||||
| Need | File |
|
| Need | File |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Run all tests | `pytest` |
|
| All tests | `pytest` |
|
||||||
| Unit tests only | `pytest -m "unit"` |
|
| Unit only | `pytest -m "unit"` |
|
||||||
| Integration tests | `pytest -m "integration"` (test_api.py auto-marked) |
|
| Integration only | `pytest -m "integration"` (test_api.py auto-marked) |
|
||||||
|
| Skip slow | `pytest -m "not slow"` |
|
||||||
| Download logic | `test_download_manager.py`, `test_downloaders.py` |
|
| Download logic | `test_download_manager.py`, `test_downloaders.py` |
|
||||||
| API endpoints | `test_api.py` |
|
| API endpoints | `test_api.py` |
|
||||||
| Provider scrapers | `test_anime_sama_*.py`, `test_french_manga.py` |
|
| Provider scrapers | `test_anime_sama_*.py`, `test_french_manga.py` |
|
||||||
@@ -40,25 +37,20 @@ tests/
|
|||||||
## CONVENTIONS
|
## CONVENTIONS
|
||||||
|
|
||||||
**Markers** (auto-applied unless manual):
|
**Markers** (auto-applied unless manual):
|
||||||
- `unit` - Default for non-api tests
|
- `unit` — Default for non-api tests
|
||||||
- `integration` - test_api.py only
|
- `integration` — test_api.py only
|
||||||
- `asyncio` - Auto-detected from coroutine functions
|
- `asyncio` — Auto-detected from coroutine functions
|
||||||
- `slow` - Manual: `@pytest.mark.slow`
|
- `slow` — Manual: `@pytest.mark.slow`
|
||||||
- `network` - Manual: `@pytest.mark.network`
|
- `network` — Manual: `@pytest.mark.network`
|
||||||
|
|
||||||
**Naming**:
|
**DB isolation**: `conftest.py` forces `DATABASE_URL=sqlite://` in-memory. Tables auto-drop/recreate per test.
|
||||||
- Files: `test_*.py`
|
|
||||||
- Classes: `Test*` (e.g., `class TestSanitizeFilename:`)
|
|
||||||
- Functions: `test_*` (e.g., `def test_sanitize_simple_filename(self):`)
|
|
||||||
|
|
||||||
**Fixtures** (in conftest.py):
|
**Naming**: Files `test_*.py`, classes `Test*`, functions `test_*`.
|
||||||
- `temp_dir` - Temporary directory (auto-cleanup)
|
|
||||||
- `temp_download_dir` - Download folder
|
|
||||||
- `sample_download_task` - DownloadTask instance
|
|
||||||
- `mock_httpx_client` - Mocked AsyncClient
|
|
||||||
- `download_manager` - Pre-configured DownloadManager
|
|
||||||
|
|
||||||
**Run commands**:
|
**Config**: `pytest.ini` — asyncio_mode=auto, timeout=300s, coverage on app/.
|
||||||
- `pytest` - All tests with coverage
|
|
||||||
- `pytest -m "not slow"` - Skip slow tests
|
## ANTI-PATTERNS
|
||||||
- `pytest --cov=app --cov-report=html` - HTML coverage report
|
|
||||||
|
- Do NOT add network-dependent tests without `@pytest.mark.network`
|
||||||
|
- Do NOT add slow tests without `@pytest.mark.slow`
|
||||||
|
- Empty `except:` in `test_api.py:429,451` and `test_download_manager.py:357` — known tech debt
|
||||||
|
|||||||
Reference in New Issue
Block a user