Compare commits
44 Commits
20bcc75b9b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6521fe3416 | |||
| 520be53901 | |||
| 693615a7dc | |||
| 7529449f86 | |||
| 555816bf30 | |||
| 2da2a5bb27 | |||
| c921aafadd | |||
| e5b30741fe | |||
| 0af537e032 | |||
| 9f9df600c1 | |||
| 5d264d8f3b | |||
| c0f9c0c1c4 | |||
| 29c051be69 | |||
| 18c3c4d27b | |||
| dd1365eff9 | |||
| b2310249f8 | |||
| d0bbda745f | |||
| 4e27bcaa13 | |||
| c94f97b357 | |||
| 844ad88f50 | |||
| d8bc00808d | |||
| 0e27d73d07 | |||
| 89291bddde | |||
| 3dc5dd8fe9 | |||
| 5d23a3d663 | |||
| 0c03f4f4a6 | |||
| 3b405f2a42 | |||
| b6f12b2162 | |||
| 9f85908ff3 | |||
| a684237725 | |||
| 96b12b66e2 | |||
| 2127cc10cd | |||
| f426b2c025 | |||
| eb0c67348f | |||
| f99e739ff2 | |||
| 4e313392d0 | |||
| 69e14afedf | |||
| 5c7116557d | |||
| 2b4cc617cb | |||
| 29c7040b20 | |||
| d4d8d8a3b6 | |||
| 1b5d7f9238 | |||
| d179694fb2 | |||
| 42daab1e50 |
@@ -0,0 +1,154 @@
|
||||
# GitHub Actions CI Workflow for Ohm Streaming
|
||||
# Runs tests, coverage, and quality checks on push and pull requests
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
# Cancel in-progress runs when a new workflow with the same group name starts
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Run pytest tests with coverage
|
||||
test:
|
||||
name: Test (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11', '3.12']
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'requirements.txt'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run pytest (exclude slow tests by default)
|
||||
run: |
|
||||
pytest -m "not slow" --cov=app --cov-report=term-missing --cov-report=html --no-cov-on-fail -v
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: actions/upload-artifact@v4
|
||||
if: success()
|
||||
with:
|
||||
name: coverage-report-${{ matrix.python-version }}
|
||||
path: htmlcov/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload test logs
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: test-logs-${{ matrix.python-version }}
|
||||
path: |
|
||||
.pytest_cache/
|
||||
*.html
|
||||
retention-days: 7
|
||||
|
||||
# Run linting with ruff
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install ruff
|
||||
|
||||
- name: Run ruff
|
||||
run: ruff check app/ --output-format=github
|
||||
|
||||
- name: Run ruff (format check)
|
||||
run: ruff format --check app/
|
||||
|
||||
# Run type checking with mypy
|
||||
type-check:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install mypy types-requests types-aiohttp types-python-jose
|
||||
|
||||
- name: Run mypy
|
||||
run: |
|
||||
mypy app/ --ignore-missing-imports --no-error-summary
|
||||
|
||||
# Summary job - runs after all other jobs
|
||||
summary:
|
||||
name: Summary
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
needs: [test, lint, type-check]
|
||||
|
||||
steps:
|
||||
- name: Create summary
|
||||
run: |
|
||||
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Test results
|
||||
echo "### Tests" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ needs.test.result }}" = "success" ]; then
|
||||
echo "✅ Tests passed for Python 3.11 and 3.12" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ Tests failed" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Lint results
|
||||
echo "### Linting" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ needs.lint.result }}" = "success" ]; then
|
||||
echo "✅ Linting passed" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ Linting failed" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Type check results
|
||||
echo "### Type Checking" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ needs.type-check.result }}" = "success" ]; then
|
||||
echo "✅ Type checking passed" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "⚠️ Type checking had issues" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
+18
-2
@@ -47,10 +47,26 @@ favorites.json
|
||||
ohm_streaming.db
|
||||
|
||||
# Config (runtime-generated)
|
||||
config/anime_sama_domain.json
|
||||
config/metadata_cache.json
|
||||
config/*.json
|
||||
config/domain_cache.json
|
||||
!config/*.example.json
|
||||
data/
|
||||
favorites.json
|
||||
*.db
|
||||
*.sqlite
|
||||
ohm_streaming.db
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
package-lock.json.tmp
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Agent/Tool specific
|
||||
.serena/
|
||||
.sisyphus/
|
||||
.claude/
|
||||
.opencode/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
playwright/.auth/
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"active_plan": "/opt/Ohm_streaming/.sisyphus/plans/watchlist-visual-redesign.md",
|
||||
"started_at": "2026-02-26T14:52:06.065Z",
|
||||
"active_plan": "/opt/Ohm_streaming/.sisyphus/plans/cors-fix.md",
|
||||
"started_at": "2026-03-18T13:17:43.401Z",
|
||||
"session_ids": [
|
||||
"ses_36604025effe0D8w29Z4LdkaPr"
|
||||
"ses_3388359e2ffe5brQanNc9Qb8FL"
|
||||
],
|
||||
"plan_name": "watchlist-visual-redesign",
|
||||
"plan_name": "cors-fix",
|
||||
"agent": "atlas"
|
||||
}
|
||||
@@ -1,353 +1,156 @@
|
||||
# AGENTS.md - Agentic Coding Guidelines
|
||||
# AGENTS.md — Ohm Stream Downloader
|
||||
|
||||
This file provides guidance for AI agents working in this repository.
|
||||
FastAPI anime/series downloader with HTMX+Alpine.js frontend, SQLModel/SQLite DB,
|
||||
3-tier scraper architecture, JWT auth, Sonarr webhooks, and auto-download scheduler.
|
||||
|
||||
## Quick Start
|
||||
## COMMANDS
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run dev server
|
||||
# Dev server
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
||||
```
|
||||
|
||||
## Build, Lint & Test Commands
|
||||
# --- Tests (pytest, configured in pytest.ini, asyncio_mode=auto) ---
|
||||
|
||||
### Running Tests
|
||||
pytest # All tests (coverage + verbose by default)
|
||||
pytest -m "unit" # Fast unit tests only
|
||||
pytest -m "integration" # API integration tests
|
||||
pytest -m "not slow" # CI default — excludes slow tests
|
||||
pytest -m "network" # Tests requiring network access
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
pytest
|
||||
|
||||
# With coverage (HTML report in htmlcov/)
|
||||
pytest --cov=app --cov-report=html
|
||||
|
||||
# Unit only (fast)
|
||||
pytest -m "unit"
|
||||
|
||||
# Integration tests only
|
||||
pytest -m "integration"
|
||||
|
||||
# Exclude slow tests
|
||||
pytest -m "not slow"
|
||||
|
||||
# Exclude network tests (mocked only)
|
||||
pytest -m "not network"
|
||||
|
||||
# Verbose with print debugging
|
||||
pytest -v -s
|
||||
|
||||
# Generate HTML report
|
||||
pytest --html=report.html --self-contained-html
|
||||
|
||||
# Timeout per test (seconds)
|
||||
pytest --timeout=30
|
||||
```
|
||||
|
||||
### Running Single Tests
|
||||
|
||||
```bash
|
||||
# Specific file
|
||||
# Single file / class / test
|
||||
pytest tests/test_sonarr.py -v
|
||||
|
||||
# Specific class
|
||||
pytest tests/test_sonarr.py::TestSonarrHandler -v
|
||||
|
||||
# Specific test
|
||||
pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
|
||||
|
||||
# Pattern match
|
||||
pytest -k "test_download" -v
|
||||
# Debug
|
||||
pytest -s # Show print() output
|
||||
pytest --cov=app --cov-report=html # HTML coverage report in htmlcov/
|
||||
|
||||
# --- Lint & Format (ruff) ---
|
||||
|
||||
ruff check app/ # Lint
|
||||
ruff format --check app/ # Format check (CI enforces this)
|
||||
ruff format app/ # Auto-format
|
||||
|
||||
# --- Type Check ---
|
||||
|
||||
mypy app/ --ignore-missing-imports # Type check (CI enforces)
|
||||
|
||||
# --- DB Migrations ---
|
||||
|
||||
alembic revision --autogenerate -m "description"
|
||||
alembic upgrade head
|
||||
|
||||
# --- Frontend (optional) ---
|
||||
|
||||
npm test # Vitest JS tests
|
||||
npx playwright test # E2E browser tests
|
||||
```
|
||||
|
||||
## Code Style
|
||||
## CODE STYLE
|
||||
|
||||
### Imports (PEP 8 order)
|
||||
1. Standard library (`os`, `json`, `asyncio`)
|
||||
2. Third-party (`httpx`, `beautifulsoup4`, `fastapi`)
|
||||
3. Local app (`app.config`, `app.utils`)
|
||||
|
||||
```python
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import DownloadTask, DownloadStatus
|
||||
```
|
||||
### Imports
|
||||
Three groups separated by blank lines: stdlib → third-party → local (`app.*`).
|
||||
Always absolute imports (no relative). No wildcard imports (`from X import *` enforced).
|
||||
|
||||
### Formatting
|
||||
- **Line length**: 120 chars max
|
||||
- **Indentation**: 4 spaces
|
||||
- **Blank lines**: 2 between top-level, 1 between inline
|
||||
PEP 8, 120 chars max line length. Single quotes for strings, double quotes for docstrings.
|
||||
Ruff handles linting and formatting (no local config — CI-only).
|
||||
|
||||
### Type Annotations
|
||||
- Use explicit types
|
||||
- Use `Optional[X]` not `X | None`
|
||||
- Use `list[X]`, `dict[X, Y]`
|
||||
### Types
|
||||
Explicit type hints on all function signatures and return types.
|
||||
Use `Optional[X]` from `typing`. Use `list[str]` / `dict[str, X]` (lowercase generics).
|
||||
Pydantic models for all API schemas. Return type annotations required on public methods.
|
||||
|
||||
```python
|
||||
# Good
|
||||
async def get_download_link(url: str, target_filename: Optional[str] = None) -> tuple[str, str]:
|
||||
results: list[dict[str, str]] = []
|
||||
|
||||
# Avoid
|
||||
async def get_download_link(url, target_filename=None):
|
||||
results = []
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Element | Convention | Example |
|
||||
|---------|------------|---------|
|
||||
| Modules | snake_case | `download_manager.py` |
|
||||
| Classes | PascalCase | `DownloadManager` |
|
||||
| Functions | snake_case | `get_download_link()` |
|
||||
| Constants | UPPER_SNAKE | `MAX_PARALLEL_DOWNLOADS` |
|
||||
| Variables | snake_case | `download_task` |
|
||||
| Enums | PascalCase | `DownloadStatus` |
|
||||
| Enum values | UPPER_SNAKE | `DownloadStatus.PENDING` |
|
||||
|
||||
### Async/Await
|
||||
- Always use for I/O operations
|
||||
- Close clients properly to avoid leaks
|
||||
|
||||
```python
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
```
|
||||
### Naming
|
||||
- `snake_case` for functions, variables, constants
|
||||
- `PascalCase` for classes and enums
|
||||
- `UPPER_SNAKE_CASE` for enum values (`DownloadStatus.PENDING`)
|
||||
- `logger = logging.getLogger(__name__)` at module level
|
||||
- `_` prefix for private methods (`_fetch_page`, `_sanitize`)
|
||||
- `get_*` for factory functions (`get_downloader`, `get_anime_site`)
|
||||
|
||||
### Error Handling
|
||||
- Use try/except for recoverable errors
|
||||
- Raise specific exceptions (`HTTPException`, `ValueError`)
|
||||
- Never use empty except blocks
|
||||
- Log errors appropriately
|
||||
- `HTTPException` for API errors with proper status codes
|
||||
- `raise ValueError()` for business logic validation
|
||||
- `try/except` with logging — never bare `except:` (known tech debt exists)
|
||||
- `response.raise_for_status()` for HTTP errors
|
||||
- Never return `None` for missing URLs from downloaders — raise an exception
|
||||
|
||||
```python
|
||||
try:
|
||||
result = await client.get(url)
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"Request timeout for {url}")
|
||||
raise HTTPException(status_code=504, detail="Request timeout")
|
||||
### Docstrings
|
||||
Triple double quotes `"""`. Module docstrings describe purpose. Class docstrings describe
|
||||
responsibility. Complex methods use Args/Returns sections. Not all methods require docstrings.
|
||||
|
||||
## ARCHITECTURE
|
||||
|
||||
```
|
||||
main.py # App entry, middleware, startup, router registration
|
||||
app/
|
||||
├── routers/ # 11 APIRouter modules (one per feature domain)
|
||||
├── downloaders/ # 3-tier: anime_sites/ → series_sites/ → video_players/
|
||||
├── models/ # Pydantic/SQLModel (Base → Table → Schema pattern)
|
||||
├── config.py # Pydantic Settings from .env
|
||||
├── database.py # SQLModel engine (created at import time)
|
||||
├── download_manager.py # Async queue, semaphore-based parallelism
|
||||
├── auth.py # JWT + bcrypt, SQLModel user storage
|
||||
├── providers.py # ANIME_PROVIDERS, SERIES_PROVIDERS, FILE_HOSTS registries
|
||||
└── utils.py # sanitize_filename(), is_safe_filename()
|
||||
templates/ # Jinja2 + HTMX + Alpine.js
|
||||
static/js/ # Vanilla ES modules (no build step)
|
||||
tests/ # pytest suite (conftest.py has shared fixtures)
|
||||
config/ # Runtime JSON files (users, watchlist, sonarr)
|
||||
alembic/ # DB migrations
|
||||
```
|
||||
|
||||
### File Operations
|
||||
- Always sanitize filenames: `app.utils.sanitize_filename()`
|
||||
- Validate paths: `app.utils.is_safe_filename()`
|
||||
## KEY CONVENTIONS
|
||||
|
||||
### Testing
|
||||
- Use pytest with pytest-asyncio
|
||||
- Mark tests: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.slow`, `@pytest.mark.network`
|
||||
- Tests in `test_api.py` are auto-marked as integration, others as unit
|
||||
- Use fixtures from `tests/conftest.py`
|
||||
- **URL pipe format**: `video_url|anime_page_url|episode_title` — metadata preserved through download pipeline
|
||||
- **Module-level init**: `database.py` creates engine on import; `main.py` creates `download_manager` on import
|
||||
- **Async everywhere**: Always `async/await` for I/O — use `httpx.AsyncClient`, never `requests`
|
||||
- **Factory pattern**: `get_downloader(url)` routes anime → series → video player → generic
|
||||
- **Router deps**: `Depends(lambda: download_manager)`, `Depends(get_current_user_from_token)`, `Depends(lambda: templates)`
|
||||
- **Dual storage**: Some features use JSON files (legacy) + SQLModel tables (newer)
|
||||
- **Frontend**: No JS build step. HTMX for server interactions, Alpine.js for client state, Plyr.io for video
|
||||
- **Circular import avoidance**: `episode_checker.py` uses lazy init (`set_download_manager()`)
|
||||
|
||||
```python
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_manager():
|
||||
manager = DownloadManager(max_parallel=3)
|
||||
assert manager.max_parallel == 3
|
||||
## ANTI-PATTERNS (DO NOT)
|
||||
|
||||
# Mark slow tests
|
||||
@pytest.mark.slow
|
||||
async def test_full_download_flow():
|
||||
...
|
||||
- Use sync `requests` — always `httpx.AsyncClient`
|
||||
- Return `None` for missing URLs from downloaders — raise an exception
|
||||
- Skip `sanitize_filename()` on extracted filenames — path traversal risk
|
||||
- Forget `await self.close()` in downloaders — resource leak
|
||||
- Hardcode User-Agent in individual players — use base class headers
|
||||
- Use `from X import *` — always explicit imports
|
||||
- Import `download_manager` from `main.py` in app/ modules — causes circular imports
|
||||
- Store secrets in `config/*.json` — use `.env`
|
||||
- Use `as any`, `@ts-ignore` to suppress type errors (if adding TS)
|
||||
|
||||
# Mark tests requiring network
|
||||
@pytest.mark.network
|
||||
async def test_external_api():
|
||||
...
|
||||
```
|
||||
## TEST CONVENTIONS
|
||||
|
||||
### Security
|
||||
- Never hardcode secrets - use environment variables
|
||||
- Validate all inputs (URLs, filenames)
|
||||
- Use HMAC for webhook verification when configured
|
||||
- Limit CORS origins - never use `*` in production
|
||||
- `tests/` directory with `conftest.py` for shared fixtures
|
||||
- Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.network`, `@pytest.mark.slow`
|
||||
- `asyncio_mode = auto` — async test functions run without explicit marker
|
||||
- Test naming: `test_<verb>_<noun>` in `Test*` classes
|
||||
- 300s timeout configured in pytest.ini; `testpaths = tests`
|
||||
- Legacy test files at project root: `test_watchlist_simple.py`, `test_watchlist_e2e.py`
|
||||
|
||||
## Architecture Patterns
|
||||
## ADDING NEW PROVIDERS
|
||||
|
||||
### Three-Tier Downloader Architecture
|
||||
**Video player**: Create in `app/downloaders/video_players/`, inherit `BaseVideoPlayer`,
|
||||
implement `can_handle()` + `get_download_link(url, target_filename=None)`, register in
|
||||
`__init__.py`, add to `FILE_HOSTS` in `providers.py`.
|
||||
|
||||
The project uses a three-tier downloader system:
|
||||
**Anime/series site**: Create in `app/downloaders/anime_sites/` or `series_sites/`, inherit
|
||||
base class, implement `search_anime()` + `get_episodes()` + `get_anime_metadata()` +
|
||||
`get_download_link()`, register in `__init__.py`, add to `providers.py`.
|
||||
|
||||
1. **Anime Catalogs** (`app/downloaders/anime_sites/`)
|
||||
- `animesama.py` - Anime-Sama (primary)
|
||||
- `animeultime.py` - Anime-Ultime
|
||||
- `nekosama.py` - Neko-Sama
|
||||
- `vostfree.py` - Vostfree
|
||||
- `frenchmanga.py` - French-Manga
|
||||
## NOTES
|
||||
|
||||
2. **Series Catalogs** (`app/downloaders/series_sites/`)
|
||||
- `fs7.py` - French Stream
|
||||
|
||||
3. **Video Players** (`app/downloaders/video_players/`)
|
||||
- `sibnet.py`, `doodstream.py`, `vidmoly.py`, `uqload.py`
|
||||
- `uptobox.py`, `unfichier.py`, `rapidfile.py`
|
||||
- `sendvid.py`, `lpayer.py`, `vidzy.py`, `luluv.py`
|
||||
- `oneupload.py`, `smoothpre.py`
|
||||
|
||||
Each tier has a base class and factory pattern. When adding providers:
|
||||
1. Inherit from appropriate base class (`base.py`)
|
||||
2. Implement required methods (`search_anime`, `get_episodes`, `get_download_link`)
|
||||
3. Register in `app/providers.py`
|
||||
4. Add URL detection patterns
|
||||
|
||||
**URL Convention**: Pipe-separated format preserves metadata:
|
||||
```
|
||||
video_url|anime_page_url|episode_title
|
||||
```
|
||||
|
||||
### Core Modules
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `app/watchlist.py` | Episode tracking & auto-download |
|
||||
| `app/auto_download_scheduler.py` | APScheduler for periodic checks |
|
||||
| `app/episode_checker.py` | New episode detection |
|
||||
| `app/sonarr_handler.py` | Sonarr webhook integration |
|
||||
| `app/recommendation_engine.py` | Personalized anime recommendations |
|
||||
| `app/favorites.py` | User favorites management |
|
||||
| `app/auth.py` | JWT authentication |
|
||||
| `app/download_manager.py` | Download queue management |
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `main.py` | FastAPI app, all API endpoints |
|
||||
| `app/config.py` | Pydantic Settings configuration |
|
||||
| `app/download_manager.py` | Download queue & task management |
|
||||
| `app/utils.py` | `sanitize_filename`, `is_safe_filename` |
|
||||
| `app/auth.py` | JWT auth, user management |
|
||||
| `app/providers.py` | Provider definitions & URL detection |
|
||||
| `app/models/__init__.py` | Core Pydantic models |
|
||||
| `app/models/watchlist.py` | Watchlist models |
|
||||
| `app/models/sonarr.py` | Sonarr integration models |
|
||||
| `app/models/auth.py` | Authentication models |
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### JavaScript Modules (`static/js/`)
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `main.js` | Application entry point |
|
||||
| `api.js` | API client functions |
|
||||
| `auth.js` | Authentication handling |
|
||||
| `tabs.js` | Tab navigation |
|
||||
| `anime.js` | Anime search & display |
|
||||
| `anime-details.js` | Anime detail views |
|
||||
| `watchlist.js` | Watchlist API calls |
|
||||
| `watchlist-ui.js` | Watchlist UI rendering |
|
||||
| `downloads.js` | Download management UI |
|
||||
| `recommendations.js` | Recommendations display |
|
||||
| `series-search.js` | TV series search |
|
||||
| `utils.js` | Utility functions |
|
||||
|
||||
### Templates (`templates/`)
|
||||
| Template | Purpose |
|
||||
|----------|---------|
|
||||
| `base.html` | Base layout with CSS/JS imports |
|
||||
| `index.html` | Main SPA interface |
|
||||
| `login.html` | Login/register page |
|
||||
| `watchlist.html` | Watchlist management page |
|
||||
| `player.html` | Video player page |
|
||||
| `components/` | Reusable HTML components |
|
||||
|
||||
## Configuration
|
||||
|
||||
- Use `.env` from `.env.example`
|
||||
- `JWT_SECRET_KEY` must change in production
|
||||
- Config files stored in `config/`:
|
||||
- `users.json` - User database
|
||||
- `watchlist.json` - Watchlist data
|
||||
- `watchlist_settings.json` - Auto-download settings
|
||||
- `sonarr.json` - Sonarr integration config
|
||||
- `sonarr_mappings.json` - Series to anime mappings
|
||||
|
||||
## API Endpoints Overview
|
||||
|
||||
### Authentication
|
||||
- `POST /api/auth/register` - Register new user
|
||||
- `POST /api/auth/login` - Login, get JWT token
|
||||
- `GET /api/auth/me` - Get current user info
|
||||
- `POST /api/auth/logout` - Logout (client-side)
|
||||
|
||||
### Downloads
|
||||
- `POST /api/download` - Create download task
|
||||
- `GET /api/downloads` - List all downloads
|
||||
- `GET /api/download/{task_id}` - Get download status
|
||||
- `POST /api/download/{task_id}/pause` - Pause download
|
||||
- `POST /api/download/{task_id}/resume` - Resume download
|
||||
- `DELETE /api/download/{task_id}` - Cancel/delete download
|
||||
- `GET /api/download/{task_id}/file` - Download completed file
|
||||
|
||||
### Anime Search & Metadata
|
||||
- `GET /api/anime/search` - Search across all anime providers
|
||||
- `GET /api/series/search` - Search TV series providers
|
||||
- `GET /api/anime/metadata` - Get detailed anime metadata
|
||||
- `GET /api/anime/episodes` - Get episode list
|
||||
- `GET /api/anime/seasons` - Get available seasons
|
||||
- `POST /api/anime/download-season` - Download all episodes
|
||||
|
||||
### Watchlist
|
||||
- `GET /api/watchlist` - List watchlist items
|
||||
- `POST /api/watchlist` - Add to watchlist
|
||||
- `PUT /api/watchlist/{item_id}` - Update watchlist item
|
||||
- `DELETE /api/watchlist/{item_id}` - Remove from watchlist
|
||||
- `GET /api/watchlist/settings` - Get auto-download settings
|
||||
- `PUT /api/watchlist/settings` - Update settings
|
||||
- `POST /api/watchlist/check` - Trigger manual episode check
|
||||
|
||||
### Favorites
|
||||
- `GET /api/favorites` - List favorites
|
||||
- `POST /api/favorites` - Add to favorites
|
||||
- `DELETE /api/favorites/{anime_id}` - Remove from favorites
|
||||
- `POST /api/favorites/toggle` - Toggle favorite status
|
||||
|
||||
### Recommendations
|
||||
- `GET /api/recommendations` - Get personalized recommendations
|
||||
- `GET /api/releases/latest` - Get latest releases
|
||||
- `GET /api/releases/seasonal` - Get seasonal anime
|
||||
|
||||
### Sonarr Integration
|
||||
- `POST /api/sonarr/webhook` - Receive Sonarr webhooks
|
||||
- `GET /api/sonarr/mappings` - List Sonarr mappings
|
||||
- `POST /api/sonarr/mappings` - Create mapping
|
||||
- `DELETE /api/sonarr/mappings/{series_id}` - Delete mapping
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core
|
||||
- `fastapi` - Web framework
|
||||
- `uvicorn` - ASGI server
|
||||
- `httpx` - Async HTTP client
|
||||
- `aiohttp` - Alternative HTTP client
|
||||
- `pydantic` / `pydantic-settings` - Data validation & settings
|
||||
|
||||
### Scraping & Parsing
|
||||
- `beautifulsoup4` - HTML parsing
|
||||
- `lxml` - XML/HTML parser
|
||||
- `jieba` - Chinese text segmentation
|
||||
|
||||
### Authentication
|
||||
- `python-jose` - JWT handling
|
||||
- `passlib[bcrypt]` - Password hashing
|
||||
|
||||
### Scheduler
|
||||
- `apscheduler` - Job scheduling for auto-downloads
|
||||
|
||||
### Cryptography
|
||||
- `pycryptodome` - AES decryption for video players
|
||||
|
||||
### Testing
|
||||
- `pytest` + `pytest-asyncio` - Async test support
|
||||
- `pytest-cov` - Coverage reporting
|
||||
- `pytest-mock` - Mocking utilities
|
||||
- `pytest-timeout` - Test timeout protection
|
||||
- `pytest-html` - HTML test reports
|
||||
- Python 3.11+, CI tests on 3.11 and 3.12
|
||||
- No `pyproject.toml` — uses `requirements.txt` with exact version pinning
|
||||
- `GEMINI.md` and `CLAUDE.md` exist with tool-specific instructions (merge if conflicting)
|
||||
- French-language project (animes, séries, VOSTFR) but all code and comments in English
|
||||
- ~20 empty `except:` blocks in downloaders/tests — known tech debt
|
||||
- `JWT_SECRET_KEY` min 32 chars, default rejected at startup; generate via `Settings.generate_secret()`
|
||||
- Sub-AGENTS.md files exist in `app/`, `app/routers/`, `app/downloaders/`, `app/models/`,
|
||||
`app/downloaders/anime_sites/`, `app/downloaders/video_players/` for deeper context
|
||||
|
||||
@@ -16,12 +16,17 @@ source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Install JavaScript test dependencies (optional, for frontend tests)
|
||||
npm install
|
||||
|
||||
# Run development server (auto-reload)
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
||||
|
||||
# Access web interface
|
||||
# Open http://localhost:3000/web in browser
|
||||
|
||||
# --- Python Tests (pytest) ---
|
||||
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
@@ -42,6 +47,26 @@ pytest -v
|
||||
|
||||
# Show print debugging
|
||||
pytest -s
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_sonarr.py -v
|
||||
|
||||
# Run specific test class
|
||||
pytest tests/test_sonarr.py::TestSonarrHandler -v
|
||||
|
||||
# Run specific test
|
||||
pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
|
||||
|
||||
# --- JavaScript Tests (vitest) ---
|
||||
|
||||
# Run all JavaScript tests
|
||||
npm test
|
||||
|
||||
# Run JavaScript tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run specific JavaScript test file
|
||||
npx vitest run static/js/__tests__/auth-api.test.js
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -49,8 +74,20 @@ pytest -s
|
||||
**Directory Structure:**
|
||||
```
|
||||
Ohm_streaming/
|
||||
├── main.py # FastAPI application & API endpoints
|
||||
├── main.py # FastAPI application startup & middleware
|
||||
├── app/
|
||||
│ ├── routers/ # FastAPI routers (API endpoints organized by feature)
|
||||
│ │ ├── __init__.py # Exports all routers
|
||||
│ │ ├── router_auth.py # /api/auth/* routes (user authentication)
|
||||
│ │ ├── router_anime.py # /api/anime/* and /api/series/* routes
|
||||
│ │ ├── router_downloads.py # /api/download/* routes
|
||||
│ │ ├── router_favorites.py # /api/favorites/* routes
|
||||
│ │ ├── router_player.py # /player/* and /watch/* routes
|
||||
│ │ ├── router_recommendations.py # /api/recommendations and /api/releases routes
|
||||
│ │ ├── router_root.py # / and /web routes
|
||||
│ │ ├── router_sonarr.py # /api/sonarr/* and /api/webhook/sonarr routes
|
||||
│ │ ├── router_static.py # /static/* and /video/* routes
|
||||
│ │ └── router_watchlist.py # /api/watchlist/* routes
|
||||
│ ├── models/ # Pydantic models (DownloadTask, AnimeMetadata, Sonarr, etc.)
|
||||
│ ├── downloaders/ # Host-specific downloaders (organized structure)
|
||||
│ │ ├── base.py # BaseDownloader abstract class (legacy, kept for compatibility)
|
||||
@@ -100,7 +137,18 @@ Ohm_streaming/
|
||||
│ ├── player.html # Video player page
|
||||
│ └── base.html # Base template
|
||||
├── static/ # Static assets (CSS, JS, images)
|
||||
└── tests/ # Test suite with fixtures
|
||||
│ ├── js/
|
||||
│ │ ├── __tests__/ # JavaScript tests (vitest)
|
||||
│ │ │ ├── auth-api.test.js
|
||||
│ │ │ ├── auth-utils.test.js
|
||||
│ │ │ └── smoke.test.js
|
||||
│ │ ├── auth.js # Authentication UI logic
|
||||
│ │ ├── auth-api.js # Authentication API client
|
||||
│ │ ├── auth-ui.js # Authentication UI components
|
||||
│ │ └── auth-utils.js # Authentication utilities
|
||||
├── tests/ # Python test suite with fixtures
|
||||
│ ├── e2e/ # End-to-end tests (Playwright)
|
||||
└── vitest.config.js # Vitest configuration for JS tests
|
||||
```
|
||||
|
||||
**Core Components:**
|
||||
@@ -188,7 +236,40 @@ The downloaders are organized into three categories with separate base classes:
|
||||
- Each provider has: name, domains, icon, color, url_pattern
|
||||
- `detect_provider_from_url(url)` - Identify provider from URL
|
||||
|
||||
### 4. API Endpoints
|
||||
### 4. Router Architecture (`app/routers/`)
|
||||
|
||||
**Overview:**
|
||||
- API endpoints have been migrated from a monolithic `main.py` (2200+ lines) to modular routers
|
||||
- Each router is responsible for a specific feature domain
|
||||
- Routers are imported and registered in `main.py` using FastAPI's APIRouter
|
||||
- This improves maintainability, testability, and code organization
|
||||
|
||||
**Router Organization:**
|
||||
- `router_auth.py` - `/api/auth/*` - User registration, login, token refresh, profile management
|
||||
- `router_anime.py` - `/api/anime/*` and `/api/series/*` - Search, metadata, episodes, downloads
|
||||
- `router_downloads.py` - `/api/download/*` - Download task management (pause, resume, cancel, delete)
|
||||
- `router_favorites.py` - `/api/favorites/*` - Favorites CRUD operations
|
||||
- `router_player.py` - `/player/*` and `/watch/*` - Video player endpoints
|
||||
- `router_recommendations.py` - `/api/recommendations` and `/api/releases/latest` - Personalization and latest releases
|
||||
- `router_root.py` - `/` and `/web` - Root and main web interface routes
|
||||
- `router_sonarr.py` - `/api/sonarr/*` and `/api/webhook/sonarr` - Sonarr integration and webhooks
|
||||
- `router_static.py` - `/static/*` and `/video/*` - Static file serving and video streaming
|
||||
- `router_watchlist.py` - `/api/watchlist/*` - Watchlist and auto-download scheduler management
|
||||
|
||||
**Key Benefits:**
|
||||
- Clear separation of concerns - each router handles one feature area
|
||||
- Easier testing - routers can be tested independently
|
||||
- Better navigation - smaller files focused on specific functionality
|
||||
- Shared dependencies via FastAPI's dependency injection (e.g., `download_manager`, `get_current_user_from_token`)
|
||||
- No URL changes - frontend remains fully compatible
|
||||
|
||||
**When Adding New Endpoints:**
|
||||
1. Identify which router the endpoint belongs to based on its URL prefix
|
||||
2. Add the endpoint function to the appropriate router file in `app/routers/`
|
||||
3. Use FastAPI dependencies for shared services (`download_manager`, `templates`, authentication)
|
||||
4. Follow existing patterns for error handling and response models
|
||||
|
||||
### 5. API Endpoints
|
||||
|
||||
**Download Management:**
|
||||
- `POST /api/download` - Create new download task
|
||||
@@ -231,13 +312,13 @@ The downloaders are organized into three categories with separate base classes:
|
||||
- `GET /api/sonarr/suggest` - Suggest anime matches
|
||||
- `POST /api/sonarr/download` - Manually trigger download
|
||||
|
||||
### 5. Web Interface
|
||||
### 6. Web Interface
|
||||
- Single-page app at `/web` (templates/index.html)
|
||||
- Auto-refreshes every second to show progress
|
||||
- Video player with seeking support (HTTP Range headers)
|
||||
- Dark theme with gradients and animations
|
||||
|
||||
### 6. Security Utilities (`app/utils.py`)
|
||||
### 7. Security Utilities (`app/utils.py`)
|
||||
- `sanitize_filename(filename, max_length=255)` - Sanitize filenames to prevent path traversal
|
||||
- Removes dangerous characters: `\ / : * ? " < > |`
|
||||
- Strips path separators and leading dots/dashes
|
||||
@@ -247,21 +328,27 @@ The downloaders are organized into three categories with separate base classes:
|
||||
- Detects absolute paths and drive letters
|
||||
- Used throughout the codebase for file operations
|
||||
|
||||
### 7. Authentication System (`app/auth.py`)
|
||||
### 8. Authentication System (`app/auth.py`)
|
||||
- **UserManager** - JSON-based user storage in `config/users.json`
|
||||
- User registration with bcrypt password hashing
|
||||
- Password truncated to 72 bytes (bcrypt limitation)
|
||||
- User authentication and last login tracking
|
||||
- **JWT Tokens** - Stateless authentication
|
||||
- 7-day token expiration (configurable via `ACCESS_TOKEN_EXPIRE_MINUTES`)
|
||||
- **JWT Tokens** - Stateless authentication with refresh token support
|
||||
- Access tokens: 24-hour expiration (configurable via `ACCESS_TOKEN_EXPIRE_MINUTES`)
|
||||
- Refresh tokens: 30-day expiration (stored in SQLite `refresh_tokens` table)
|
||||
- HS256 algorithm with JWT_SECRET_KEY (change in production!)
|
||||
- Token verification and user extraction
|
||||
- **Password Security**
|
||||
- bcrypt hashing with passlib
|
||||
- Automatic deprecated scheme migration
|
||||
- **JWT Secret Validation** (in `app/config.py`)
|
||||
- Default secret is rejected at startup (security enforcement)
|
||||
- Minimum 32 characters required
|
||||
- Use `Settings.generate_secret()` to generate secure secrets
|
||||
- **Configuration**
|
||||
- `JWT_SECRET_KEY` environment variable (default: dev-secret-change-in-production)
|
||||
- `JWT_SECRET_KEY` environment variable (MUST be changed from default)
|
||||
- Users stored in `config/users.json`
|
||||
- Refresh tokens stored in SQLite `refresh_tokens` table
|
||||
|
||||
**Authentication Endpoints:**
|
||||
- `POST /api/auth/register` - User registration
|
||||
@@ -269,19 +356,19 @@ The downloaders are organized into three categories with separate base classes:
|
||||
- `GET /api/auth/me` - Get current user profile
|
||||
- `PUT /api/auth/me` - Update user profile
|
||||
|
||||
### 8. Recommendation Engine (`app/recommendation_engine.py`)
|
||||
### 9. Recommendation Engine (`app/recommendation_engine.py`)
|
||||
- Analyzes download history to generate personalized recommendations
|
||||
- Tracks genre preferences and viewing patterns
|
||||
- Scores anime based on user's download history
|
||||
- Used by `/api/recommendations` endpoint
|
||||
|
||||
### 9. Kitsu API (`app/kitsu_api.py`)
|
||||
### 10. Kitsu API (`app/kitsu_api.py`)
|
||||
- Integrates with Kitsu anime database for metadata
|
||||
- Fetches anime information by title or ID
|
||||
- Provides enriched metadata (synopsis, genres, ratings, poster images)
|
||||
- Used as fallback when provider metadata is incomplete
|
||||
|
||||
### 10. Watchlist & Auto-Download System
|
||||
### 11. Watchlist & Auto-Download System
|
||||
|
||||
**WatchlistManager** (`app/watchlist.py`):
|
||||
- JSON-based storage in `config/watchlist.json`
|
||||
@@ -328,7 +415,7 @@ The downloaders are organized into three categories with separate base classes:
|
||||
- `POST /api/watchlist/scheduler/start` - Start scheduler
|
||||
- `POST /api/watchlist/scheduler/stop` - Stop scheduler
|
||||
|
||||
### 11. Pydantic Models (`app/models/`)
|
||||
### 12. Pydantic Models (`app/models/`)
|
||||
- **`__init__.py`** - Core models:
|
||||
- `DownloadStatus` - Enum for task states (PENDING, DOWNLOADING, PAUSED, COMPLETED, FAILED, CANCELLED)
|
||||
- `HostType` - Enum for file host types (RAPIDFILE, UNFICHIER, DOODSTREAM, OTHER)
|
||||
@@ -355,7 +442,7 @@ The downloaders are organized into three categories with separate base classes:
|
||||
|
||||
## Test Structure
|
||||
|
||||
**Test Organization (tests/):**
|
||||
**Python Test Organization (tests/):**
|
||||
- `conftest.py` - Pytest configuration and fixtures
|
||||
- `test_models.py` - Pydantic model tests
|
||||
- `test_downloaders.py` - Downloader tests
|
||||
@@ -367,6 +454,15 @@ The downloaders are organized into three categories with separate base classes:
|
||||
- `test_translate_api.py` - Translation API tests
|
||||
- `test_delete_and_restore.py` - Delete and restore functionality tests
|
||||
- `test_french_manga.py` - French-Manga provider tests
|
||||
- `test_jwt_secret_validation.py` - JWT secret key validation tests
|
||||
- `test_token_refresh.py` - Token refresh functionality tests
|
||||
|
||||
**JavaScript Test Organization (static/js/__tests__/):**
|
||||
- `smoke.test.js` - Basic smoke tests
|
||||
- `auth-api.test.js` - Authentication API client tests
|
||||
- `auth-utils.test.js` - Authentication utility function tests
|
||||
- Uses Vitest with jsdom environment
|
||||
- Coverage reports generated in `htmlcov/` (shared with Python tests)
|
||||
|
||||
**Fixtures in conftest.py:**
|
||||
- `temp_dir` - Temporary directory
|
||||
@@ -550,6 +646,41 @@ To add a new anime streaming provider:
|
||||
Metadata should include:
|
||||
- synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status
|
||||
|
||||
## Working with Routers
|
||||
|
||||
**Adding New Endpoints:**
|
||||
1. Identify which router handles the URL prefix you need
|
||||
2. Edit the appropriate router file in `app/routers/`
|
||||
3. Use FastAPI's APIRouter pattern with proper dependencies
|
||||
4. Import the router in `app/routers/__init__.py` if creating a new router
|
||||
5. Register the router in `main.py`
|
||||
|
||||
**Example - Adding a new endpoint to router_anime.py:**
|
||||
```python
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.download_manager import DownloadManager
|
||||
|
||||
router = APIRouter(prefix="/api/anime", tags=["anime"])
|
||||
|
||||
@router.get("/custom-endpoint")
|
||||
async def custom_endpoint(
|
||||
download_manager: DownloadManager = Depends(lambda: download_manager)
|
||||
):
|
||||
# Your logic here
|
||||
return {"status": "success"}
|
||||
```
|
||||
|
||||
**Common Dependencies:**
|
||||
- `download_manager: DownloadManager = Depends(lambda: download_manager)` - Access download queue
|
||||
- `current_user: User = Depends(get_current_user_from_token)` - Authenticated user
|
||||
- `templates: Jinja2Templates = Depends(lambda: templates)` - Template rendering
|
||||
|
||||
**Router Organization Principles:**
|
||||
- Group related endpoints by URL prefix
|
||||
- Keep routers focused on a single feature area
|
||||
- Use dependency injection for shared services
|
||||
- Tag routers for OpenAPI documentation
|
||||
|
||||
## Configuration
|
||||
|
||||
The application uses environment variables for configuration via `app/config.py` (Pydantic Settings).
|
||||
@@ -571,12 +702,14 @@ CORS_ORIGINS=... # Comma-separated allowed origins
|
||||
HTTP_TIMEOUT=10.0 # HTTP request timeout (seconds)
|
||||
DOWNLOAD_TIMEOUT=300 # Download timeout (seconds)
|
||||
LOG_LEVEL=INFO # Logging level
|
||||
JWT_SECRET_KEY=change-me-in-production # JWT signing key for auth
|
||||
JWT_SECRET_KEY=change-me-in-production # JWT signing key (MUST be changed, min 32 chars)
|
||||
# Generate a secure key with: python -c "from app.config import Settings; print(Settings.generate_secret())"
|
||||
```
|
||||
|
||||
**Configuration Files:**
|
||||
- `.env` - Environment configuration (create from .env.example)
|
||||
- `config/users.json` - User authentication database (created automatically)
|
||||
- `refresh_tokens` table - Refresh token storage (SQLite database)
|
||||
- `config/sonarr.json` - Sonarr webhook configuration (created automatically)
|
||||
- `config/sonarr_mappings.json` - Sonarr to anime provider mappings (created automatically)
|
||||
- `config/watchlist.json` - User watchlist items (created automatically)
|
||||
@@ -607,10 +740,13 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key for auth
|
||||
- Configured in `main.py` via environment variables
|
||||
|
||||
**Authentication:**
|
||||
- JWT token-based authentication with 7-day expiration
|
||||
- JWT token-based authentication with 24-hour access token expiration
|
||||
- Refresh token support with 30-day expiration
|
||||
- bcrypt password hashing with passlib
|
||||
- Passwords truncated to 72 bytes (bcrypt limitation)
|
||||
- JWT secret key validation (minimum 32 characters, default rejected)
|
||||
- Credentials stored in `config/users.json`
|
||||
- Refresh tokens stored in SQLite `refresh_tokens` table
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
@@ -654,11 +790,17 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key for auth
|
||||
- passlib[bcrypt] - Password hashing
|
||||
- python-jose[cryptography] - JWT token handling
|
||||
- apscheduler - Task scheduling for auto-download
|
||||
- pydantic-settings - Environment-based configuration
|
||||
|
||||
**Testing:**
|
||||
**Python Testing:**
|
||||
- pytest - Test framework
|
||||
- pytest-asyncio - Async test support
|
||||
- pytest-cov - Coverage reporting
|
||||
- pytest-mock - Mocking support
|
||||
- pytest-timeout - Test timeout handling
|
||||
- pytest-html - HTML test reports
|
||||
|
||||
**JavaScript Testing (optional, for frontend):**
|
||||
- vitest - Fast JavaScript test runner
|
||||
- jsdom - DOM implementation for tests
|
||||
- @playwright/test - End-to-end browser testing
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# GEMINI.md - Project Context & Instructions
|
||||
|
||||
This file provides foundational context and instructions for AI agents working on the **Ohm Stream Downloader** project.
|
||||
|
||||
## 🚀 Project Overview
|
||||
|
||||
**Ohm Stream Downloader** is a full-stack web application designed for searching, streaming, and downloading anime and TV series from various French and international providers. It features a modern SPA-like interface, automated watchlist tracking, and integration with ecosystem tools like Sonarr.
|
||||
|
||||
- **Backend:** Python 3.11+ with **FastAPI**, Uvicorn, Pydantic (v2), and APScheduler.
|
||||
- **Frontend:** Vanilla JavaScript (modular), Jinja2 templates, and CSS.
|
||||
- **Testing:** Pytest (backend), Vitest & Playwright (frontend).
|
||||
- **Architecture:** Modular routers and a specialized three-tier downloader system.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Quick Start
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
npm install # For frontend tests
|
||||
```
|
||||
|
||||
### Running the Application
|
||||
```bash
|
||||
# Start development server (Port 3000)
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
||||
```
|
||||
Access the web interface at `http://localhost:3000/web`.
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
# Backend (Pytest)
|
||||
pytest # Run all tests
|
||||
pytest -m "unit" # Fast unit tests
|
||||
pytest -m "integration" # API integration tests
|
||||
|
||||
# Frontend (Vitest)
|
||||
npm test # Run JS tests
|
||||
npx playwright test # E2E tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture & Core Logic
|
||||
|
||||
### Three-Tier Downloader System
|
||||
Logic is separated into three distinct layers in `app/downloaders/`:
|
||||
1. **Anime/Series Catalogs** (`anime_sites/`, `series_sites/`): Handles searching, metadata extraction (synopsis, posters), and episode listing (e.g., Anime-Sama, FS7).
|
||||
2. **Video Players** (`video_players/`): Extracts direct download links from embedded players (e.g., VidMoly, DoodStream, 1fichier).
|
||||
3. **Download Manager** (`app/download_manager.py`): Orchestrates the actual file transfer, supporting parallel downloads, pause/resume (via HTTP Range), and progress tracking.
|
||||
|
||||
### Key Modules
|
||||
- `app/routers/`: Modular API endpoints (Auth, Anime, Watchlist, Sonarr, etc.).
|
||||
- `app/watchlist.py`: User-specific tracking and automated episode detection.
|
||||
- `app/sonarr_handler.py`: Webhook integration for Sonarr.
|
||||
- `static/js/`: Feature-scoped frontend logic (api.js, auth.js, watchlist-ui.js, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 📝 Development Conventions
|
||||
|
||||
### Coding Style (Python)
|
||||
- **Formatting:** PEP 8, 120 chars max line length.
|
||||
- **Typing:** Use explicit Pydantic models and type hints (`Optional[X]`, `list[X]`).
|
||||
- **Async:** Always use `async/await` for I/O (httpx, aiofiles).
|
||||
- **Naming:** `snake_case` for functions/variables, `PascalCase` for classes/enums.
|
||||
|
||||
### Security & Safety
|
||||
- **Filename Sanitization:** ALWAYS use `app.utils.sanitize_filename()` before any disk write.
|
||||
- **Path Validation:** Use `app.utils.is_safe_filename()` to prevent traversal attacks.
|
||||
- **Authentication:** JWT-based. `JWT_SECRET_KEY` must be at least 32 chars and never the default.
|
||||
- **Secrets:** Never hardcode. Use `.env` (via `app/config.py`).
|
||||
|
||||
### Testing Requirements
|
||||
- All new features **must** include tests in `tests/`.
|
||||
- Use pytest markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.network`.
|
||||
- Verify changes with `pytest --cov=app` to ensure coverage.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
- **Environment:** `.env` file (see `.env.example`).
|
||||
- **JSON Storage:** Data persists in `config/` (users, watchlist, sonarr mappings).
|
||||
- **Downloads:** Default directory is `downloads/`.
|
||||
|
||||
## 📂 Key File Map
|
||||
| Path | Purpose |
|
||||
| :--- | :--- |
|
||||
| `main.py` | App entry & middleware |
|
||||
| `app/models/` | Pydantic schemas |
|
||||
| `app/routers/` | API Route definitions |
|
||||
| `app/downloaders/` | Provider-specific scraping logic |
|
||||
| `templates/` | HTML (Jinja2) |
|
||||
| `static/js/` | Frontend logic |
|
||||
| `config/` | Persistent JSON data |
|
||||
|
||||
---
|
||||
*For detailed developer guides, refer to `CLAUDE.md` and `AGENTS.md`.*
|
||||
@@ -1,408 +1,222 @@
|
||||
# Ohm Stream Downloader
|
||||
|
||||
**Application web complète pour télécharger des animes et fichiers depuis divers hébergeurs.**
|
||||
**Application web complète pour rechercher, streamer et télécharger des animes, séries TV et films.**
|
||||
|
||||
Interface moderne avec recherche d'anime, métadonnées enrichies, téléchargements parallèles et streaming vidéo.
|
||||
Interface moderne (SPA-like) avec recherche unifiée, watchlist automatique, métadonnées enrichies, téléchargements parallèles et intégration Sonarr. Propulsée par FastAPI, SQLModel et une interface dynamique HTMX/Alpine.js.
|
||||
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
### 🎬 Recherche et Téléchargement d'Animes
|
||||
- **Recherche unifiée** : Recherchez sur 4 providers simultanément (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree)
|
||||
- **Métadonnées riches** : Synopsis, genres, notes, année de sortie, studio, nombre d'épisodes, statut
|
||||
- **Téléchargement par épisode** : Sélectionnez et téléchargez des épisodes individuels
|
||||
- **Téléchargement de saison complète** : Téléchargez tous les épisodes d'un coup
|
||||
- **Streaming vidéo** : Regardez vos animes directement dans le navigateur
|
||||
- **Recherche floue** : Gestion des fautes de frappe et variations de noms
|
||||
### 🎬 Recherche & Streaming
|
||||
- **Recherche unifiée** : Recherchez animes et séries TV simultanément via plusieurs sources.
|
||||
- **Providers Anime** : Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga.
|
||||
- **Providers Séries** : FS7 (French-Stream), Zone-Telechargement.
|
||||
- **Métadonnées riches** : Synopsis, genres, notes, studio via intégration Kitsu/MAL.
|
||||
- **Streaming vidéo** : Lecteur **Plyr.io** intégré supportant plus de 15 hébergeurs.
|
||||
- **Téléchargement flexible** : Épisode par épisode ou saison complète.
|
||||
|
||||
### 📁 Hébergeurs de Fichiers Supportés
|
||||
- **1fichier** (1fichier.com, 1fichier.fr)
|
||||
- **Uptobox** (uptobox.com, uptobox.fr)
|
||||
- **Doodstream** (doodstream.com, dood.to, dood.lol, etc.)
|
||||
- **Rapidfile** (rapidfile.net, rapidfile.com)
|
||||
### 🔐 Authentification
|
||||
- **Inscription / Connexion** : Système JWT avec tokens d'accès et de refresh.
|
||||
- **Sécurité** : Clé secrète configurable, tokens expirables (24h access, 30j refresh).
|
||||
|
||||
### 🎥 Hébergeurs Vidéo Supportés
|
||||
- **VidMoly** (vidmoly.to, vidmoly.com)
|
||||
- **SendVid** (sendvid.com)
|
||||
### 📋 Watchlist & Automatisation
|
||||
- **Suivi intelligent** : Ajoutez des titres à votre watchlist pour ne rater aucun épisode.
|
||||
- **Auto-Download** : Téléchargement automatique des nouveaux épisodes dès leur parution.
|
||||
- **Planificateur (Scheduler)** : Vérification périodique configurable (1h à 168h).
|
||||
- **Filtres avancés** : Visualisation par statut (Actif, En pause, Terminé).
|
||||
- **Intégration Sonarr** : Support des webhooks pour une automatisation complète du homelab.
|
||||
|
||||
### 🚀 Gestion des Téléchargements
|
||||
- **Téléchargements parallèles** : Jusqu'à 3 téléchargements simultanés
|
||||
- **Pause/Reprise** : Contrôle total sur vos téléchargements
|
||||
- **Progression en temps réel** : Vitesse, progression, taille
|
||||
- **Reprise automatique** : Support des HTTP Range pour reprendre les téléchargements interrompus
|
||||
### ⭐ Favoris & Recommandations
|
||||
- **Favoris** : Sauvegardez vos animes préférés avec tri et filtres.
|
||||
- **Recommandations** : Suggestions basées sur les tendances et sorties récentes.
|
||||
- **Sorties saisonnières** : Suivi des sorties anime (top, latest, seasonal).
|
||||
|
||||
### 🌐 Interface Web
|
||||
- **Design moderne** : Interface sombre avec gradients et animations
|
||||
- **Responsive** : Fonctionne sur desktop et mobile
|
||||
- **Mise à jour automatique** : Rafraîchissement chaque seconde
|
||||
- **Métadonnées visuelles** : Affichage des informations anime avec icônes
|
||||
### 🚀 Gestionnaire de Téléchargements
|
||||
- **Multi-threading** : Jusqu'à 5 téléchargements simultanés avec gestion de file d'attente.
|
||||
- **Pause/Reprise** : Support du protocole HTTP Range pour reprendre les téléchargements interrompus.
|
||||
- **Progression Temps Réel** : Vitesse (Mo/s), pourcentage et estimation du temps restant.
|
||||
- **Sanitisation** : Nettoyage automatique des noms de fichiers pour une compatibilité maximale.
|
||||
|
||||
### 🔌 API REST
|
||||
- **Endpoints REST** : Intégration facile avec d'autres applications
|
||||
- **Documentation automatique** : Swagger UI disponible
|
||||
### ⚙️ Paramètres
|
||||
- **Désactivation de providers** : Activez/désactivez les sources individuellement.
|
||||
- **UI Settings** : Configuration de l'interface utilisateur.
|
||||
- **Sonarr Config** : Configuration de l'intégration Sonarr avec mapping de séries.
|
||||
|
||||
## 📋 Configuration Requise
|
||||
## 🏗️ Architecture & Stack Technique
|
||||
|
||||
- Python 3.8+
|
||||
- pip
|
||||
L'application repose sur une architecture moderne et robuste :
|
||||
- **Backend** : Python 3.11+, **FastAPI** pour l'API asynchrone.
|
||||
- **Base de Données** : **SQLModel** (SQLAlchemy + Pydantic) avec **SQLite**.
|
||||
- **Migrations** : **Alembic** pour la gestion évolutive du schéma de données.
|
||||
- **Frontend** : **HTMX** pour les interactions serveur, **Alpine.js** pour l'état client, **Vanilla CSS**.
|
||||
- **Streaming** : **Plyr.io** pour une expérience de lecture fluide et moderne.
|
||||
- **Authentification** : JWT via **python-jose** + **passlib/bcrypt**.
|
||||
|
||||
## 🚀 Installation
|
||||
## 📁 Hébergeurs Supportés
|
||||
|
||||
| Type | Services Supportés |
|
||||
| :--- | :--- |
|
||||
| **Catalogues Anime** | Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga |
|
||||
| **Catalogues Séries** | FS7 (French-Stream), Zone-Telechargement |
|
||||
| **Players/Hosts** | VidMoly, DoodStream, 1fichier, Uptobox, SendVid, Sibnet, Lplayer, Uqload, Rapidfile, LuLuvid, Smoothpre, Vidzy, OneUpload |
|
||||
|
||||
## 📊 État des Providers
|
||||
|
||||
| Provider | Type | Status |
|
||||
| :--- | :--- | :--- |
|
||||
| Anime-Sama | Anime | ✅ UP |
|
||||
| Neko-Sama | Anime | ✅ UP |
|
||||
| Anime-Ultime | Anime | ✅ UP |
|
||||
| Vostfree | Anime | ✅ UP |
|
||||
| French-Manga | Anime | ✅ UP |
|
||||
| FS7 | Séries | ✅ UP |
|
||||
| Zone-Telechargement | Séries | ✅ UP |
|
||||
|
||||
> Dernière vérification : Avril 2026
|
||||
|
||||
## 🚀 Installation & Configuration
|
||||
|
||||
### 1. Prérequis
|
||||
- Python 3.11+
|
||||
- Node.js (pour les tests optionnels uniquement)
|
||||
- Playwright (requis pour l'extraction de certains lecteurs comme VidMoly)
|
||||
|
||||
### 2. Installation
|
||||
```bash
|
||||
# Cloner le repository
|
||||
git clone https://github.com/votre-user/Ohm_streaming.git
|
||||
cd Ohm_streaming
|
||||
git clone https://git.lanro.eu/Roman/ohm_streaming.git
|
||||
cd ohm_streaming
|
||||
|
||||
# Créer l'environnement virtuel
|
||||
# Créer et activer l'environnement virtuel
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
source venv/bin/activate
|
||||
|
||||
# Installer les dépendances
|
||||
pip install -r requirements.txt
|
||||
pip install pydantic[email] # Requis pour la validation des emails
|
||||
|
||||
# Lancer le serveur de développement
|
||||
# Initialisation Playwright (optionnel, pour l'extraction VidMoly)
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
### 3. Configuration
|
||||
Créez un fichier `.env` à la racine du projet à partir du modèle :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**Générez une clé secrète JWT sécurisée** (obligatoire, min. 32 caractères) :
|
||||
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
```
|
||||
|
||||
Editez le `.env` et ajoutez :
|
||||
```env
|
||||
JWT_SECRET_KEY=<la_clé_générée_ci_dessus>
|
||||
```
|
||||
|
||||
> ⚠️ **Ne pas** définir `CORS_ORIGINS` dans le `.env` si vous utilisez les valeurs par défaut (format JSON requis, les valeurs par défaut du code suffisent).
|
||||
|
||||
### 4. Lancement
|
||||
```bash
|
||||
# Lancer l'application (Port 3000 par défaut)
|
||||
source venv/bin/activate
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
||||
```
|
||||
|
||||
Accédez à l'interface : http://localhost:3000/web
|
||||
|
||||
## 📖 Utilisation
|
||||
|
||||
### Interface Web
|
||||
|
||||
1. **Onglet Recherche d'Anime** :
|
||||
- Entrez le nom d'un anime (ex: "Naruto", "One Piece")
|
||||
- Sélectionnez la langue (VOSTFR ou VF)
|
||||
- Cochez "Inclure les métadonnées" pour plus d'informations
|
||||
- Cliquez sur "Rechercher"
|
||||
- Sélectionnez un épisode et cliquez sur "Télécharger"
|
||||
- Ou utilisez "Toute la saison" pour tout télécharger
|
||||
|
||||
2. **Onglet Lien Direct** :
|
||||
- Collez un lien de téléchargement direct
|
||||
- Cliquez sur "Télécharger"
|
||||
|
||||
3. **Onglet Providers** :
|
||||
- Utilisez les onglets spécifiques à chaque provider
|
||||
- Chaque onglet a ses propres options de recherche
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Téléchargements
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| POST | `/api/download` | Créer un nouveau téléchargement |
|
||||
| GET | `/api/downloads` | Lister tous les téléchargements |
|
||||
| GET | `/api/download/{task_id}` | Statut d'un téléchargement |
|
||||
| POST | `/api/download/{task_id}/pause` | Mettre en pause |
|
||||
| POST | `/api/download/{task_id}/resume` | Reprendre |
|
||||
| DELETE | `/api/download/{task_id}` | Annuler/Supprimer |
|
||||
| GET | `/api/download/{task_id}/file` | Télécharger le fichier terminé |
|
||||
|
||||
#### Anime
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/api/anime/search` | Rechercher un anime (paramètres: `q`, `lang`, `include_metadata`) |
|
||||
| GET | `/api/anime/metadata` | Obtenir les métadonnées d'un anime (paramètre: `url`) |
|
||||
| GET | `/api/anime/episodes` | Liste des épisodes d'un anime (paramètres: `url`, `lang`) |
|
||||
| POST | `/api/anime/download` | Télécharger un épisode |
|
||||
| POST | `/api/anime/download-season` | Télécharger toute une saison |
|
||||
|
||||
#### Streaming Vidéo
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/video/{task_id}` | Stream une vidéo (support Range/seeking) |
|
||||
| GET | `/stream/{filename}` | Stream par nom de fichier |
|
||||
| GET | `/player/{task_id}` | Lecteur vidéo pour un téléchargement |
|
||||
| GET | `/watch/{filename}` | Lecteur vidéo par nom de fichier |
|
||||
|
||||
#### Système
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/` | Informations sur l'API |
|
||||
| GET | `/api/providers` | Liste des providers supportés |
|
||||
| GET | `/health` | Vérifier l'état du serveur |
|
||||
| GET | `/web` | Interface web |
|
||||
|
||||
### Exemples API
|
||||
|
||||
**Rechercher un anime avec métadonnées :**
|
||||
Ou via le script fourni :
|
||||
```bash
|
||||
curl "http://localhost:3000/api/anime/search?q=naruto&lang=vostfr&include_metadata=true"
|
||||
./run_app.sh
|
||||
```
|
||||
|
||||
**Obtenir les épisodes d'un anime :**
|
||||
```bash
|
||||
curl "http://localhost:3000/api/anime/episodes?url=https://anime-sama.si/catalogue/naruto/saison1/vostfr/&lang=vostfr"
|
||||
```
|
||||
**Points d'accès :**
|
||||
- Interface web : `http://localhost:3000/web`
|
||||
- Documentation API : `http://localhost:3000/docs`
|
||||
- Page de connexion : `http://localhost:3000/login`
|
||||
|
||||
**Télécharger une saison complète :**
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/anime/download-season?url=https://anime-sama.si/catalogue/naruto/saison1/vostfr/&lang=vostfr"
|
||||
```
|
||||
## 🧪 Tests & Qualité
|
||||
|
||||
**Créer un téléchargement direct :**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/download \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://1fichier.com/?xxxxx"}'
|
||||
# Backend (Pytest)
|
||||
pytest # Tous les tests
|
||||
pytest -m "unit" # Tests unitaires rapides
|
||||
|
||||
# Frontend (Vitest & Playwright)
|
||||
npm install # Installer les dépendances dev
|
||||
npm test # Tests unitaires JS (Vitest)
|
||||
npx playwright test # Tests E2E complets
|
||||
```
|
||||
|
||||
## 🏗️ Structure du Projet
|
||||
|
||||
```
|
||||
Ohm_streaming/
|
||||
├── main.py # Application FastAPI & endpoints API
|
||||
ohm_streaming/
|
||||
├── main.py # Point d'entrée & Middleware FastAPI
|
||||
├── app/
|
||||
│ ├── models/ # Modèles Pydantic
|
||||
│ │ └── __init__.py # DownloadTask, AnimeMetadata, etc.
|
||||
│ ├── downloaders/ # Downloaders par provider
|
||||
│ │ ├── base.py # Classe BaseDownloader
|
||||
│ │ ├── animesama.py # Anime-Sama (avec métadonnées)
|
||||
│ │ ├── animeultime.py # Anime-Ultime (avec métadonnées)
|
||||
│ │ ├── nekosama.py # Neko-Sama (avec métadonnées)
|
||||
│ │ ├── vostfree.py # Vostfree (avec métadonnées)
|
||||
│ │ ├── unfichier.py # 1fichier
|
||||
│ │ ├── uptobox.py # Uptobox
|
||||
│ │ ├── doodstream.py # Doodstream
|
||||
│ │ ├── rapidfile.py # Rapidfile
|
||||
│ │ ├── vidmoly.py # VidMoly
|
||||
│ │ ├── sendvid.py # SendVid
|
||||
│ │ └── __init__.py # Registry des downloaders
|
||||
│ ├── providers.py # Configuration des providers
|
||||
│ └── download_manager.py # Gestionnaire de file d'attente
|
||||
├── downloads/ # Fichiers téléchargés
|
||||
├── templates/
|
||||
│ ├── index.html # Interface web principale
|
||||
│ └── player.html # Lecteur vidéo
|
||||
├── static/ # Fichiers statiques (CSS, JS, images)
|
||||
└── requirements.txt # Dépendances Python
|
||||
│ ├── downloaders/ # Logique d'extraction (Scraping multi-tier)
|
||||
│ │ ├── anime_sama.py # Downloader Anime-Sama
|
||||
│ │ ├── anime_ultime.py # Downloader Anime-Ultime
|
||||
│ │ ├── neko_sama.py # Downloader Neko-Sama
|
||||
│ │ ├── vostfree.py # Downloader Vostfree
|
||||
│ │ ├── french_manga.py # Downloader French-Manga
|
||||
│ │ ├── fs7.py # Downloader FS7
|
||||
│ │ └── zone_telechargement.py # Downloader Zone-TG
|
||||
│ ├── models/ # Modèles SQLModel & Pydantic
|
||||
│ ├── routers/ # Routes API modulaires (~40 endpoints)
|
||||
│ ├── download_manager.py # Moteur de téléchargement asynchrone
|
||||
│ ├── watchlist.py # Logique métier du suivi
|
||||
│ ├── episode_checker.py # Vérification automatique de nouveaux épisodes
|
||||
│ ├── auto_download_scheduler.py # Planificateur de téléchargements
|
||||
│ ├── sonarr_handler.py # Intégration Sonarr
|
||||
│ ├── metadata_enrichment.py # Enrichissement des métadonnées (Kitsu/MAL)
|
||||
│ ├── recommendations.py # Système de recommandations
|
||||
│ ├── providers_manager.py # Gestion des providers (health check, activation)
|
||||
│ └── database.py # Configuration de la base de données
|
||||
├── config/ # Fichiers de configuration (Sonarr, mappings)
|
||||
├── alembic/ # Migrations de base de données
|
||||
├── static/ # Frontend (JS, CSS, Images)
|
||||
├── templates/ # Vues Jinja2 (avec HTMX & Alpine.js)
|
||||
├── tests/ # Tests backend
|
||||
├── scripts/ # Scripts utilitaires
|
||||
└── downloads/ # Répertoire par défaut des médias
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
## 🔧 Endpoints API Principaux
|
||||
|
||||
Modifiez ces paramètres dans `main.py` :
|
||||
| Endpoint | Méthode | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `/api/auth/register` | POST | Création de compte |
|
||||
| `/api/auth/login` | POST | Connexion (JWT) |
|
||||
| `/api/auth/me` | GET | Profil utilisateur |
|
||||
| `/api/anime/search?q=` | GET | Recherche multi-providers |
|
||||
| `/api/series/search?q=` | GET | Recherche séries |
|
||||
| `/api/anime/seasons?url=` | GET | Liste des saisons |
|
||||
| `/api/anime/episodes?url=` | GET | Liste des épisodes |
|
||||
| `/api/anime/download?url=` | POST | Lancer un téléchargement |
|
||||
| `/api/anime/download-season?url=` | POST | Télécharger une saison complète |
|
||||
| `/api/downloads` | GET | Liste des téléchargements |
|
||||
| `/api/favorites` | GET | Liste des favoris |
|
||||
| `/api/watchlist` | GET | Liste de la watchlist |
|
||||
| `/api/providers/health` | GET | État des providers |
|
||||
| `/api/settings` | GET | Configuration |
|
||||
| `/api/sonarr/config` | GET/POST | Configuration Sonarr |
|
||||
|
||||
```python
|
||||
download_manager = DownloadManager(
|
||||
download_dir="downloads", # Répertoire de stockage
|
||||
max_parallel=3 # Téléchargements simultanés
|
||||
)
|
||||
```
|
||||
## 🐛 Problèmes Connus
|
||||
|
||||
## 🔧 Ajouter un Provider
|
||||
- **Smoothpre** : L'extracteur de liens vidéo peut échouer si la structure de la page change côté serveur.
|
||||
- **Sibnet filename** : Le nom de fichier généré peut contenir des caractères invalides issus de l'URL (à corriger dans la sanitisation du DownloadManager).
|
||||
- **Anime-Ultime download** : La méthode `get_download_link()` a une incompatibilité de signature lors de l'appel par le routeur de téléchargement.
|
||||
- **Table watchlist_settings** : La table SQLite n'est pas créée automatiquement au premier lancement (affiche un warning dans les logs mais n'empêche pas le fonctionnement).
|
||||
|
||||
### Ajouter un Hébergeur de Fichiers
|
||||
## 📝 Licence & Sécurité
|
||||
|
||||
1. Créez `app/downloaders/myhost.py` :
|
||||
```python
|
||||
from .base import BaseDownloader
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
class MyHostDownloader(BaseDownloader):
|
||||
def can_handle(self, url: str) -> bool:
|
||||
return "myhost.com" in url.lower()
|
||||
|
||||
async def get_download_link(self, url: str) -> tuple[str, str]:
|
||||
# Extraire le lien de téléchargement direct
|
||||
response = await self.client.get(url)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
# ... logique d'extraction ...
|
||||
return download_url, filename
|
||||
```
|
||||
|
||||
2. Ajoutez-le dans `app/providers.py` :
|
||||
```python
|
||||
FILE_HOSTS = {
|
||||
# ...
|
||||
"myhost": {
|
||||
"name": "MyHost",
|
||||
"domains": ["myhost.com"],
|
||||
"icon": "📁",
|
||||
"color": "#4ecdc4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ajouter un Provider Anime avec Métadonnées
|
||||
|
||||
1. Créez le downloader avec les méthodes requises :
|
||||
```python
|
||||
class MyAnimeDownloader(BaseDownloader):
|
||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False):
|
||||
# Implémenter la recherche
|
||||
|
||||
async def get_anime_metadata(self, anime_url: str) -> dict:
|
||||
# Extraire: synopsis, genres, rating, release_year, studio, etc.
|
||||
return {
|
||||
'synopsis': '...',
|
||||
'genres': ['Action', 'Aventure'],
|
||||
'rating': '8.5/10',
|
||||
'release_year': 2023,
|
||||
'studio': 'Studio Name',
|
||||
# ...
|
||||
}
|
||||
|
||||
async def get_episodes(self, anime_url: str, lang: str = "vostfr"):
|
||||
# Retourner la liste des épisodes
|
||||
```
|
||||
|
||||
2. Enregistrez-le dans `app/providers.py` et `main.py`
|
||||
|
||||
## 🗺️ Roadmap / Plans Futurs
|
||||
|
||||
### Version 2.2 - Système de Favoris ✅ (Terminé)
|
||||
- [x] **Favoris** : Sauvegarder les animes favoris avec métadonnées complètes
|
||||
- [x] **API REST complète** : 6 endpoints pour gérer les favoris
|
||||
- [x] **Tri et filtrage** : Par titre, rating, année, provider, genre
|
||||
- [x] **Statistiques** : Distribution par provider et genre
|
||||
- [x] **Stockage persistant** : Base JSON (favorites.json)
|
||||
|
||||
### Version 2.3 - Base de Données & Authentification
|
||||
- [ ] **SQLite avec SQLAlchemy** : Persistance complète des données
|
||||
- [ ] **Système d'authentification local** :
|
||||
- [ ] Inscription et connexion utilisateur
|
||||
- [ ] Tokens JWT avec expiration (7 jours)
|
||||
- [ ] Hachage de mot de passe bcrypt
|
||||
- [ ] Préférences utilisateur personnalisables
|
||||
- [ ] **Profils utilisateurs** :
|
||||
- [ ] Table User : username, email, preferences, admin
|
||||
- [ ] Historique de téléchargement par utilisateur
|
||||
- [ ] Historique de visionnage (position, progression)
|
||||
- [ ] Préférences : langue par défaut, thème, auto-download
|
||||
- [ ] **Rétrocompatibilité** : Accès anonyme toujours possible
|
||||
|
||||
**Nouveaux endpoints :**
|
||||
- `POST /api/auth/register` - Inscription
|
||||
- `POST /api/auth/login` - Connexion (JWT)
|
||||
- `GET /api/auth/me` - Profil utilisateur
|
||||
- `PUT /api/auth/me/preferences` - Préférences
|
||||
- `GET /api/auth/me/download-history` - Historique
|
||||
- `GET /api/auth/me/watch-history` - Visionnage
|
||||
|
||||
### Version 2.4 - APIs Externes & Recommandations
|
||||
- [ ] **Intégration Jikan API** (MyAnimeList) :
|
||||
- [ ] Métadonnées enrichies (poster, notes, genres)
|
||||
- [ ] Limitation de débit : 3 req/sec
|
||||
- [ ] **Intégration AniList API** (GraphQL) :
|
||||
- [ ] Recommandations basées sur l'historique
|
||||
- [ ] Limitation de débit : 90 req/min
|
||||
- [ ] **Système de cache** :
|
||||
- [ ] Cache API dans la base de données
|
||||
- [ ] TTL configurable (168h par défaut)
|
||||
- [ ] Mécanisme de fallback (AniList → Jikan)
|
||||
- [ ] **Enrichissement automatique** :
|
||||
- [ ] Fusion des données providers + API externes
|
||||
- [ [ ] Affichage des posters dans les résultats
|
||||
|
||||
**Nouveaux endpoints :**
|
||||
- `GET /api/anime/metadata?enrich=true` - Métadonnées enrichies
|
||||
- `GET /api/recommendations` - Suggestions personnalisées
|
||||
|
||||
### Version 2.5 - Webhooks & Automatisation ✅ (Terminé)
|
||||
- [x] **Support Sonarr Webhook** :
|
||||
- [x] `POST /api/webhook/sonarr` - Réception événements
|
||||
- [x] Auto-téléchargement des nouveaux épisodes
|
||||
- [x] Vérification HMAC SHA256 (optionnel)
|
||||
- [x] Gestion des événements : Download, Rename, Delete
|
||||
- [x] **Automatisations** :
|
||||
- [x] Déclenchement automatique sur nouvel épisode
|
||||
- [x] Analyse des infos épisodes depuis Sonarr
|
||||
- [x] Mapping automatique vers les providers
|
||||
- [x] Système de mapping series Sonarr → anime providers
|
||||
- [x] Configuration API pour webhooks et mappings
|
||||
|
||||
**Nouveaux endpoints :**
|
||||
- `POST /api/webhook/sonarr` - Webhook principal Sonarr
|
||||
- `POST /api/webhook/test/sonarr` - Test de payload
|
||||
- `GET /api/sonarr/config` - Configuration webhook
|
||||
- `PUT /api/sonarr/config` - Mise à jour configuration
|
||||
- `GET /api/sonarr/mappings` - Liste des mappings
|
||||
- `POST /api/sonarr/mappings` - Créer mapping
|
||||
- `DELETE /api/sonarr/mappings/{id}` - Supprimer mapping
|
||||
- `GET /api/sonarr/search` - Rechercher anime
|
||||
- `GET /api/sonarr/episodes` - Liste épisodes
|
||||
- `GET /api/sonarr/suggest` - Suggestions mappings
|
||||
- `POST /api/sonarr/download` - Déclencher téléchargement manuel
|
||||
|
||||
**Documentation :** Voir [docs/SONARR_INTEGRATION.md](docs/SONARR_INTEGRATION.md)
|
||||
|
||||
### Version 2.6 - Gestion de Bibliothèque Avancée
|
||||
- [ ] **Bibliothèque personnelle** : Gérer sa collection d'anime téléchargés
|
||||
- [ ] **Statistiques détaillées** :
|
||||
- [ ] Temps de visionnage total
|
||||
- [ ] Espace disque utilisé
|
||||
- [ ] Animes les plus regardés
|
||||
- [ ] Graphiques de statistiques
|
||||
- [ ] **Marquage d'épisodes** :
|
||||
- [ ] Marquer épisodes comme vus/non vus
|
||||
- [ ] Système de progression automatique
|
||||
- [ ] Reprendre la lecture là où on s'est arrêté
|
||||
- [ ] **Listes de lecture** : Créer des playlists personnalisées
|
||||
- [ ] **Notes personnelles** : Noter les animes et laisser des commentaires
|
||||
|
||||
### Version 2.7 - Qualité et Formats
|
||||
- [ ] **Sélection de qualité** : Choisir entre 1080p, 720p, 480p
|
||||
- [ ] **Conversion automatique** : Convertir en différents formats
|
||||
- [ ] **Compression** : Réduire la taille des fichiers
|
||||
- [ ] **Extraction de sous-titres** : Télécharger les subs automatiquement
|
||||
- [ ] **Multi-audio** : Gérer les versions VF/VOSTFR
|
||||
|
||||
### Version 3.0 - Fonctionnalités Sociales & Mobile
|
||||
- [ ] **Fonctionnalités sociales** :
|
||||
- [ ] Partage de listes avec amis
|
||||
- [ ] Système de commentaires et avis
|
||||
- [ ] Intégration Discord/Telegram (notifications)
|
||||
- [ ] **Mobile & PWA** :
|
||||
- [ ] Application mobile native iOS/Android
|
||||
- [ ] Progressive Web App pour offline
|
||||
- [ ] Chromecast/AirPlay support
|
||||
- [ ] Interface optimisée mobile
|
||||
|
||||
### Version 4.0 - Fonctionnalités Avancées
|
||||
- [ ] **Sauvegarde cloud** : Sync avec Google Drive/Dropbox
|
||||
- [ ] **Streaming distant** : Regarder partout
|
||||
- [ ] **Multi-utilisateurs** : Profils et permissions
|
||||
- [ ] **API publique** : API pour développeurs tiers
|
||||
- [ ] **Plugins** : Système d'extensions
|
||||
|
||||
### Améliorations Continues
|
||||
- [ ] **Performance** : Optimisation du chargement et de l'interface
|
||||
- [ ] **Accessibilité** : Support lecteur d'écran, clavier
|
||||
- [ ] **Tests automatisés** : Suite de tests E2E
|
||||
- [ ] **Documentation** : Guides d'utilisation et API
|
||||
- [ ] **Internationalisation** : Support multilingue complet
|
||||
|
||||
## 🤝 Contribution
|
||||
|
||||
Les contributions sont les bienvenues !
|
||||
|
||||
1. Fork le projet
|
||||
2. Créez une branche (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push (`git push origin feature/AmazingFeature`)
|
||||
5. Ouvrez une Pull Request
|
||||
|
||||
## 📝 Licence
|
||||
|
||||
Ce projet est à usage éducatif uniquement. Respectez les droits d'auteur et les lois locales.
|
||||
|
||||
## ⚠️ Avertissement
|
||||
|
||||
Ce logiciel est destiné à un usage personnel et éducatif. Les utilisateurs sont responsables de vérifier qu'ils ont le droit de télécharger du contenu protégé par des droits d'auteur dans leur juridiction.
|
||||
|
||||
## 📧 Support
|
||||
|
||||
Pour les bugs et suggestions :
|
||||
- Ouvrez une issue sur GitHub
|
||||
- Discutez avec la communauté
|
||||
- Ce projet est à usage **éducatif et personnel** uniquement.
|
||||
- Respectez les droits d'auteur et les conditions d'utilisation des sites sources.
|
||||
- L'utilisation de ce logiciel est sous votre entière responsabilité.
|
||||
|
||||
---
|
||||
|
||||
**Version actuelle : 2.4**
|
||||
**Dernière mise à jour : Avril 2026**
|
||||
**Développé avec ❤️ pour la communauté anime**
|
||||
|
||||
*Version actuelle : 2.1*
|
||||
*Dernière mise à jour : Janvier 2026*
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -0,0 +1,85 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from sqlmodel import SQLModel
|
||||
import app.models.auth
|
||||
import app.models.watchlist
|
||||
import app.models.favorites
|
||||
import app.models.sonarr
|
||||
from app.database import DATABASE_URL
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
# Set the sqlalchemy.url to the one we use in our app
|
||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: 0001_initial_schema
|
||||
Revises:
|
||||
Create Date: 2026-05-12 08:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0001_initial_schema'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('username', sa.String(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
sa.Column('full_name', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_admin', sa.Boolean(), nullable=False),
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('hashed_password', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_login', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('username')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=False)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
|
||||
op.create_table(
|
||||
'app_settings',
|
||||
sa.Column('default_lang', sa.String(), nullable=False),
|
||||
sa.Column('theme', sa.String(), nullable=False),
|
||||
sa.Column('disabled_providers_json', sa.String(), nullable=False),
|
||||
sa.Column('recommendations_filter', sa.String(), nullable=False),
|
||||
sa.Column('releases_filter', sa.String(), nullable=False),
|
||||
sa.Column('anime_enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('series_enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('download_dir', sa.String(), nullable=False),
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id')
|
||||
)
|
||||
op.create_index(op.f('ix_app_settings_id'), 'app_settings', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_app_settings_user_id'), 'app_settings', ['user_id'], unique=True)
|
||||
|
||||
op.create_table(
|
||||
'favorites',
|
||||
sa.Column('anime_id', sa.String(), nullable=False),
|
||||
sa.Column('title', sa.String(), nullable=False),
|
||||
sa.Column('url', sa.String(), nullable=False),
|
||||
sa.Column('provider', sa.String(), nullable=False),
|
||||
sa.Column('poster_url', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('metadata_json', sa.String(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_favorites_anime_id'), 'favorites', ['anime_id'], unique=False)
|
||||
op.create_index(op.f('ix_favorites_id'), 'favorites', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_favorites_title'), 'favorites', ['title'], unique=False)
|
||||
op.create_index(op.f('ix_favorites_user_id'), 'favorites', ['user_id'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
'refresh_tokens',
|
||||
sa.Column('token_id', sa.String(), nullable=False),
|
||||
sa.Column('username', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('revoked', sa.Boolean(), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('token_id')
|
||||
)
|
||||
op.create_index(op.f('ix_refresh_tokens_id'), 'refresh_tokens', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_refresh_tokens_token_id'), 'refresh_tokens', ['token_id'], unique=True)
|
||||
op.create_index(op.f('ix_refresh_tokens_username'), 'refresh_tokens', ['username'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
'sonarr_config',
|
||||
sa.Column('webhook_enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('webhook_secret', sa.String(), nullable=True),
|
||||
sa.Column('auto_download_enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('default_language', sa.String(), nullable=False),
|
||||
sa.Column('default_quality', sa.String(), nullable=True),
|
||||
sa.Column('default_provider', sa.String(), nullable=False),
|
||||
sa.Column('verify_hmac', sa.Boolean(), nullable=False),
|
||||
sa.Column('log_webhooks', sa.Boolean(), nullable=False),
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sonarr_config_id'), 'sonarr_config', ['id'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
'sonarr_mappings',
|
||||
sa.Column('sonarr_series_id', sa.Integer(), nullable=False),
|
||||
sa.Column('sonarr_title', sa.String(), nullable=False),
|
||||
sa.Column('anime_provider', sa.String(), nullable=False),
|
||||
sa.Column('anime_url', sa.String(), nullable=False),
|
||||
sa.Column('anime_title', sa.String(), nullable=False),
|
||||
sa.Column('lang', sa.String(), nullable=False),
|
||||
sa.Column('quality_preference', sa.String(), nullable=True),
|
||||
sa.Column('auto_download', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('sonarr_series_id')
|
||||
)
|
||||
op.create_index(op.f('ix_sonarr_mappings_id'), 'sonarr_mappings', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_sonarr_mappings_sonarr_series_id'), 'sonarr_mappings', ['sonarr_series_id'], unique=True)
|
||||
op.create_index(op.f('ix_sonarr_mappings_user_id'), 'sonarr_mappings', ['user_id'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
'watchlist_items',
|
||||
sa.Column('anime_title', sa.String(), nullable=False),
|
||||
sa.Column('anime_url', sa.String(), nullable=False),
|
||||
sa.Column('provider_id', sa.String(), nullable=False),
|
||||
sa.Column('lang', sa.String(), nullable=False),
|
||||
sa.Column('last_checked', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_episode_downloaded', sa.Integer(), nullable=False),
|
||||
sa.Column('total_episodes', sa.Integer(), nullable=True),
|
||||
sa.Column('auto_download', sa.Boolean(), nullable=False),
|
||||
sa.Column('quality_preference', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('poster_image', sa.String(), nullable=True),
|
||||
sa.Column('cover_image', sa.String(), nullable=True),
|
||||
sa.Column('synopsis', sa.String(), nullable=True),
|
||||
sa.Column('genres_json', sa.String(), nullable=True),
|
||||
sa.Column('added_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_watchlist_items_anime_title'), 'watchlist_items', ['anime_title'], unique=False)
|
||||
op.create_index(op.f('ix_watchlist_items_id'), 'watchlist_items', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_watchlist_items_user_id'), 'watchlist_items', ['user_id'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
'watchlist_settings',
|
||||
sa.Column('check_interval_hours', sa.Integer(), nullable=False),
|
||||
sa.Column('auto_download_enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('max_concurrent_auto_downloads', sa.Integer(), nullable=False),
|
||||
sa.Column('notify_on_new_episodes', sa.Boolean(), nullable=False),
|
||||
sa.Column('include_completed_anime', sa.Boolean(), nullable=False),
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_watchlist_settings_id'), 'watchlist_settings', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_watchlist_settings_user_id'), 'watchlist_settings', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_watchlist_settings_user_id'), table_name='watchlist_settings')
|
||||
op.drop_index(op.f('ix_watchlist_settings_id'), table_name='watchlist_settings')
|
||||
op.drop_table('watchlist_settings')
|
||||
op.drop_index(op.f('ix_watchlist_items_user_id'), table_name='watchlist_items')
|
||||
op.drop_index(op.f('ix_watchlist_items_id'), table_name='watchlist_items')
|
||||
op.drop_index(op.f('ix_watchlist_items_anime_title'), table_name='watchlist_items')
|
||||
op.drop_table('watchlist_items')
|
||||
op.drop_index(op.f('ix_sonarr_mappings_user_id'), table_name='sonarr_mappings')
|
||||
op.drop_index(op.f('ix_sonarr_mappings_sonarr_series_id'), table_name='sonarr_mappings')
|
||||
op.drop_index(op.f('ix_sonarr_mappings_id'), table_name='sonarr_mappings')
|
||||
op.drop_table('sonarr_mappings')
|
||||
op.drop_index(op.f('ix_sonarr_config_id'), table_name='sonarr_config')
|
||||
op.drop_table('sonarr_config')
|
||||
op.drop_index(op.f('ix_refresh_tokens_username'), table_name='refresh_tokens')
|
||||
op.drop_index(op.f('ix_refresh_tokens_token_id'), table_name='refresh_tokens')
|
||||
op.drop_index(op.f('ix_refresh_tokens_id'), table_name='refresh_tokens')
|
||||
op.drop_table('refresh_tokens')
|
||||
op.drop_index(op.f('ix_favorites_user_id'), table_name='favorites')
|
||||
op.drop_index(op.f('ix_favorites_title'), table_name='favorites')
|
||||
op.drop_index(op.f('ix_favorites_id'), table_name='favorites')
|
||||
op.drop_index(op.f('ix_favorites_anime_id'), table_name='favorites')
|
||||
op.drop_table('favorites')
|
||||
op.drop_index(op.f('ix_app_settings_user_id'), table_name='app_settings')
|
||||
op.drop_index(op.f('ix_app_settings_id'), table_name='app_settings')
|
||||
op.drop_table('app_settings')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
||||
@@ -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`
|
||||
+250
-90
@@ -1,122 +1,122 @@
|
||||
"""User authentication and management system"""
|
||||
import json
|
||||
import os
|
||||
import hashlib
|
||||
import hmac
|
||||
"""User authentication and management system with SQLModel support"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
import logging
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlmodel import Session, select
|
||||
from app.database import engine
|
||||
from app.models.auth import UserTable, RefreshTokenTable
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load settings at module level for easier mocking and access
|
||||
settings = get_settings()
|
||||
|
||||
# Password hashing context
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# JWT Secret key - SHOULD BE CONFIGURED VIA ENV
|
||||
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
|
||||
# Users database file
|
||||
USERS_DB_FILE = "config/users.json"
|
||||
|
||||
|
||||
class UserManager:
|
||||
"""Manages user storage and authentication"""
|
||||
"""Manages user storage and authentication using SQL database"""
|
||||
|
||||
def __init__(self, db_file: str = USERS_DB_FILE):
|
||||
self.db_file = db_file
|
||||
self.users: Dict[str, dict] = {}
|
||||
self._load_users()
|
||||
def __init__(self):
|
||||
# Database connection is managed via engine and sessions
|
||||
pass
|
||||
|
||||
def _load_users(self):
|
||||
"""Load users from JSON file"""
|
||||
try:
|
||||
if os.path.exists(self.db_file):
|
||||
with open(self.db_file, 'r', encoding='utf-8') as f:
|
||||
self.users = json.load(f)
|
||||
logger.info(f"Loaded {len(self.users)} users from database")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading users: {e}")
|
||||
self.users = {}
|
||||
|
||||
def _save_users(self):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.db_file), exist_ok=True)
|
||||
temp_file = f"{self.db_file}.tmp"
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.users, f, indent=2, ensure_ascii=False, default=str)
|
||||
os.replace(temp_file, self.db_file)
|
||||
logger.info(f"Saved {len(self.users)} users to database")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving users: {e}")
|
||||
|
||||
def get_user(self, username: str) -> Optional[dict]:
|
||||
def get_user(self, username: str) -> Optional[UserTable]:
|
||||
"""Get user by username"""
|
||||
return self.users.get(username)
|
||||
from app.models.watchlist import WatchlistItemTable # Force registration
|
||||
|
||||
def get_user_by_id(self, user_id: str) -> Optional[dict]:
|
||||
with Session(engine) as session:
|
||||
statement = select(UserTable).where(UserTable.username == username)
|
||||
return session.exec(statement).first()
|
||||
|
||||
def get_user_by_id(self, user_id: str) -> Optional[UserTable]:
|
||||
"""Get user by ID"""
|
||||
for user in self.users.values():
|
||||
if user.get('id') == user_id:
|
||||
return user
|
||||
return None
|
||||
with Session(engine) as session:
|
||||
statement = select(UserTable).where(UserTable.id == user_id)
|
||||
return session.exec(statement).first()
|
||||
|
||||
def create_user(self, username: str, password: str, email: str = None, full_name: str = None) -> dict:
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
email: Optional[str] = None,
|
||||
full_name: Optional[str] = None,
|
||||
) -> UserTable:
|
||||
"""Create a new user"""
|
||||
if username in self.users:
|
||||
raise ValueError(f"Username '{username}' already exists")
|
||||
with Session(engine) as session:
|
||||
# Check if user already exists
|
||||
statement = select(UserTable).where(UserTable.username == username)
|
||||
if session.exec(statement).first():
|
||||
raise ValueError(f"Username '{username}' already exists")
|
||||
|
||||
# Truncate password to 72 bytes if necessary (bcrypt limitation)
|
||||
password_bytes = password.encode('utf-8')
|
||||
if len(password_bytes) > 72:
|
||||
password = password_bytes[:72].decode('utf-8', errors='ignore')
|
||||
# Truncate password to 72 bytes if necessary (bcrypt limitation)
|
||||
password_bytes = password.encode("utf-8")
|
||||
if len(password_bytes) > 72:
|
||||
password = password_bytes[:72].decode("utf-8", errors="ignore")
|
||||
|
||||
# Hash password
|
||||
hashed_password = pwd_context.hash(password)
|
||||
# Hash password
|
||||
hashed_password = pwd_context.hash(password)
|
||||
|
||||
# Create user
|
||||
user = {
|
||||
"id": hashlib.sha256(username.encode()).hexdigest()[:32],
|
||||
"username": username,
|
||||
"email": email,
|
||||
"full_name": full_name,
|
||||
"hashed_password": hashed_password,
|
||||
"is_active": True,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"last_login": None
|
||||
}
|
||||
# Create user
|
||||
user = UserTable(
|
||||
username=username,
|
||||
email=email,
|
||||
full_name=full_name,
|
||||
hashed_password=hashed_password,
|
||||
is_active=True,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
|
||||
self.users[username] = user
|
||||
self._save_users()
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
logger.info(f"Created user: {username}")
|
||||
return user
|
||||
logger.info(f"Created user: {username}")
|
||||
return user
|
||||
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[dict]:
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[UserTable]:
|
||||
"""Authenticate user with username and password"""
|
||||
user = self.get_user(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not pwd_context.verify(password, user["hashed_password"]):
|
||||
if not pwd_context.verify(password, user.hashed_password):
|
||||
return None
|
||||
|
||||
# Update last login
|
||||
user["last_login"] = datetime.now().isoformat()
|
||||
self._save_users()
|
||||
with Session(engine) as session:
|
||||
db_user = session.get(UserTable, user.id)
|
||||
if db_user:
|
||||
db_user.last_login = datetime.now()
|
||||
session.add(db_user)
|
||||
session.commit()
|
||||
session.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
return user
|
||||
|
||||
def update_last_login(self, username: str):
|
||||
"""Update user's last login time"""
|
||||
user = self.get_user(username)
|
||||
if user:
|
||||
user["last_login"] = datetime.now().isoformat()
|
||||
self._save_users()
|
||||
def update_user(self, user_id: str, update_data: dict) -> Optional[UserTable]:
|
||||
"""Update user information"""
|
||||
with Session(engine) as session:
|
||||
db_user = session.get(UserTable, user_id)
|
||||
if not db_user:
|
||||
return None
|
||||
|
||||
for key, value in update_data.items():
|
||||
if hasattr(db_user, key):
|
||||
setattr(db_user, key, value)
|
||||
|
||||
session.add(db_user)
|
||||
session.commit()
|
||||
session.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
|
||||
# Global user manager instance
|
||||
@@ -135,7 +135,9 @@ def get_password_hash(password: str) -> str:
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
|
||||
"""Create JWT access token"""
|
||||
from jose import jwt
|
||||
SECRET_KEY = settings.jwt_secret_key
|
||||
ALGORITHM = settings.jwt_algorithm
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minutes
|
||||
|
||||
to_encode = data.copy()
|
||||
|
||||
@@ -152,9 +154,11 @@ def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
|
||||
|
||||
def verify_token(token: str) -> Optional[str]:
|
||||
"""Verify JWT token and return username"""
|
||||
from jose import jwt
|
||||
from jose.exceptions import JWTError
|
||||
|
||||
SECRET_KEY = settings.jwt_secret_key
|
||||
ALGORITHM = settings.jwt_algorithm
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
@@ -169,11 +173,7 @@ def verify_token(token: str) -> Optional[str]:
|
||||
get_user_from_token = verify_token
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials) -> dict:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials) -> dict:
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials) -> UserTable:
|
||||
"""Get current user from JWT token"""
|
||||
token = credentials.credentials
|
||||
username = verify_token(token)
|
||||
@@ -181,7 +181,167 @@ def get_current_user(credentials: HTTPAuthorizationCredentials) -> dict:
|
||||
user = user_manager.get_user(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
if not user.get("is_active", True):
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="Inactive user")
|
||||
return user
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
||||
|
||||
|
||||
def _get_refresh_token(token_id: str) -> Optional[RefreshTokenTable]:
|
||||
"""Get a refresh token from the database by token_id"""
|
||||
with Session(engine) as session:
|
||||
statement = select(RefreshTokenTable).where(RefreshTokenTable.token_id == token_id)
|
||||
return session.exec(statement).first()
|
||||
|
||||
|
||||
def _save_refresh_token(token: RefreshTokenTable):
|
||||
"""Save or update a refresh token in the database"""
|
||||
with Session(engine) as session:
|
||||
session.add(token)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _revoke_refresh_token_db(token_id: str) -> bool:
|
||||
"""Revoke a refresh token in the database"""
|
||||
with Session(engine) as session:
|
||||
statement = select(RefreshTokenTable).where(RefreshTokenTable.token_id == token_id)
|
||||
db_token = session.exec(statement).first()
|
||||
if not db_token:
|
||||
return False
|
||||
db_token.revoked = True
|
||||
db_token.revoked_at = datetime.now()
|
||||
session.add(db_token)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
|
||||
def _get_jwt_config() -> dict:
|
||||
return {
|
||||
"SECRET_KEY": settings.jwt_secret_key,
|
||||
"ALGORITHM": settings.jwt_algorithm,
|
||||
"ACCESS_TOKEN_EXPIRE_MINUTES": settings.access_token_expire_minutes,
|
||||
"REFRESH_TOKEN_EXPIRE_DAYS": 30,
|
||||
}
|
||||
|
||||
|
||||
def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
|
||||
"""
|
||||
Create both access and refresh tokens.
|
||||
|
||||
Access token: short-lived (24 hours by default)
|
||||
Refresh token: long-lived (30 days by default)
|
||||
|
||||
Returns: (access_token, refresh_token)
|
||||
"""
|
||||
from jose import jwt
|
||||
import secrets
|
||||
|
||||
jwt_config = _get_jwt_config()
|
||||
|
||||
# Create access token (short-lived)
|
||||
access_expire = datetime.utcnow() + timedelta(
|
||||
minutes=jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"]
|
||||
)
|
||||
access_data = data.copy()
|
||||
access_data.update({"exp": access_expire, "type": "access"})
|
||||
access_token = jwt.encode(
|
||||
access_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
|
||||
)
|
||||
|
||||
# Create refresh token (long-lived)
|
||||
refresh_expire = datetime.utcnow() + timedelta(
|
||||
days=jwt_config["REFRESH_TOKEN_EXPIRE_DAYS"]
|
||||
)
|
||||
# Generate a unique token ID
|
||||
token_id = secrets.token_urlsafe(32)
|
||||
refresh_data = {
|
||||
"sub": data["sub"],
|
||||
"token_id": token_id,
|
||||
"exp": refresh_expire,
|
||||
"type": "refresh",
|
||||
}
|
||||
refresh_token = jwt.encode(
|
||||
refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
|
||||
)
|
||||
|
||||
# Store refresh token in database
|
||||
db_token = RefreshTokenTable(
|
||||
token_id=token_id,
|
||||
username=data["sub"],
|
||||
created_at=datetime.now(),
|
||||
expires_at=refresh_expire,
|
||||
revoked=False,
|
||||
)
|
||||
_save_refresh_token(db_token)
|
||||
|
||||
return access_token, refresh_token
|
||||
|
||||
|
||||
def verify_refresh_token(token: str) -> Optional[str]:
|
||||
"""
|
||||
Verify refresh token and return username if valid.
|
||||
Returns None if token is invalid or expired.
|
||||
"""
|
||||
from jose import jwt
|
||||
from jose.exceptions import JWTError
|
||||
|
||||
jwt_config = _get_jwt_config()
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]
|
||||
)
|
||||
|
||||
# Verify this is a refresh token
|
||||
if payload.get("type") != "refresh":
|
||||
return None
|
||||
|
||||
username = payload.get("sub")
|
||||
token_id = payload.get("token_id")
|
||||
|
||||
if not username or not token_id:
|
||||
return None
|
||||
|
||||
# Check if token exists in database
|
||||
stored_token = _get_refresh_token(token_id)
|
||||
|
||||
if not stored_token:
|
||||
return None
|
||||
|
||||
# Verify token hasn't been revoked or expired
|
||||
if stored_token.revoked:
|
||||
return None
|
||||
|
||||
# Also check expiration in database
|
||||
if stored_token.expires_at and stored_token.expires_at < datetime.now():
|
||||
return None
|
||||
|
||||
return username
|
||||
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def revoke_refresh_token(token: str) -> bool:
|
||||
"""
|
||||
Revoke a refresh token.
|
||||
Returns True if token was revoked, False if not found.
|
||||
"""
|
||||
from jose import jwt
|
||||
from jose.exceptions import JWTError
|
||||
|
||||
jwt_config = _get_jwt_config()
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]
|
||||
)
|
||||
token_id = payload.get("token_id")
|
||||
|
||||
if not token_id:
|
||||
return False
|
||||
|
||||
return _revoke_refresh_token_db(token_id)
|
||||
|
||||
except JWTError:
|
||||
return False
|
||||
|
||||
@@ -9,6 +9,7 @@ from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from app.watchlist import watchlist_manager, WatchlistManager
|
||||
from app.episode_checker import EpisodeChecker, episode_checker
|
||||
from app.providers_manager import providers_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,6 +24,7 @@ class AutoDownloadScheduler:
|
||||
):
|
||||
self.wlm = wlm or watchlist_manager
|
||||
self.checker = checker or episode_checker
|
||||
self.providers_mgr = providers_manager
|
||||
self.scheduler: Optional[AsyncIOScheduler] = None
|
||||
self._running = False
|
||||
|
||||
@@ -46,6 +48,14 @@ class AutoDownloadScheduler:
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scheduled check job: {e}", exc_info=True)
|
||||
|
||||
async def _health_check_job(self):
|
||||
"""Job function that runs periodically to check provider health"""
|
||||
try:
|
||||
logger.info("Running scheduled provider health check...")
|
||||
await self.providers_mgr.check_all_health()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in health check job: {e}")
|
||||
|
||||
def start(self):
|
||||
"""Start the scheduler"""
|
||||
if self._running:
|
||||
@@ -56,10 +66,10 @@ class AutoDownloadScheduler:
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
|
||||
# Get initial check interval from settings
|
||||
settings = self.wlm.get_settings()
|
||||
settings = self.wlm.settings
|
||||
interval_hours = settings.check_interval_hours
|
||||
|
||||
# Add the job
|
||||
# Add the job for episode checking
|
||||
self.scheduler.add_job(
|
||||
self._check_job,
|
||||
trigger=IntervalTrigger(hours=interval_hours),
|
||||
@@ -68,6 +78,15 @@ class AutoDownloadScheduler:
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# Add the job for provider health check (every 6 hours)
|
||||
self.scheduler.add_job(
|
||||
self._health_check_job,
|
||||
trigger=IntervalTrigger(hours=6),
|
||||
id='provider_health',
|
||||
name='Check provider health',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# Start the scheduler
|
||||
self.scheduler.start()
|
||||
self._running = True
|
||||
@@ -149,6 +168,15 @@ class AutoDownloadScheduler:
|
||||
logger.error(f"Error in manual check: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def trigger_health_check_now(self):
|
||||
"""Manually trigger a health check now"""
|
||||
logger.info("Manually triggering provider health check...")
|
||||
try:
|
||||
await self._health_check_job()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in manual health check: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
auto_download_scheduler = AutoDownloadScheduler()
|
||||
|
||||
+38
-2
@@ -1,7 +1,11 @@
|
||||
"""Application configuration using environment variables"""
|
||||
import secrets
|
||||
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import model_validator
|
||||
from typing import List
|
||||
import os
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables"""
|
||||
@@ -16,6 +20,38 @@ class Settings(BaseSettings):
|
||||
port: int = 3000
|
||||
reload: bool = True
|
||||
|
||||
# Authentication
|
||||
jwt_secret_key: str = "dev-secret-change-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 # 24 hours (short-lived for security)
|
||||
refresh_token_expire_days: int = 30
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_jwt_secret_key(self) -> "Settings":
|
||||
"""Validate JWT_SECRET_KEY is not the default or too short"""
|
||||
default_secret = "dev-secret-change-in-production"
|
||||
|
||||
if self.jwt_secret_key == default_secret:
|
||||
raise ValueError(
|
||||
f"JWT_SECRET_KEY cannot be the default value '{default_secret}'. "
|
||||
f"Please set a secure secret in your .env file. "
|
||||
f"Use Settings.generate_secret() to generate a secure secret."
|
||||
)
|
||||
|
||||
if len(self.jwt_secret_key) < 32:
|
||||
raise ValueError(
|
||||
f"JWT_SECRET_KEY must be at least 32 characters long. "
|
||||
f"Current length: {len(self.jwt_secret_key)} characters. "
|
||||
f"Use Settings.generate_secret() to generate a secure secret."
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def generate_secret() -> str:
|
||||
"""Generate a cryptographically secure JWT secret key"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
# Downloads
|
||||
download_dir: str = "downloads"
|
||||
max_parallel_downloads: int = 3
|
||||
@@ -26,7 +62,7 @@ class Settings(BaseSettings):
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://192.168.1.204:3000",
|
||||
"http://192.168.1.204"
|
||||
"http://192.168.1.204",
|
||||
]
|
||||
|
||||
# Storage
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Database configuration and session management using SQLModel"""
|
||||
import os
|
||||
from typing import Generator
|
||||
from sqlalchemy import create_engine
|
||||
from sqlmodel import SQLModel, Session, create_engine
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Database URL can be overridden by environment variable DATABASE_URL
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./ohm_streaming.db")
|
||||
|
||||
# Create the engine
|
||||
# connect_args={"check_same_thread": False} is required for SQLite and FastAPI
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
|
||||
|
||||
def create_db_and_tables():
|
||||
"""Create the database and tables based on the models"""
|
||||
# CRITICAL: Import ALL models here to ensure they are registered with SQLModel.metadata
|
||||
from app.models.auth import UserTable, RefreshTokenTable
|
||||
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable
|
||||
from app.models.favorites import FavoriteTable
|
||||
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
||||
from app.models.settings import AppSettingsTable
|
||||
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
# Add new columns to existing tables if they don't exist (SQLite workaround)
|
||||
_ensure_columns(engine)
|
||||
|
||||
|
||||
def _ensure_columns(engine):
|
||||
"""Add new columns to app_settings table if they don't exist"""
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
inspector = inspect(engine)
|
||||
if 'app_settings' not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
existing = {col['name'] for col in inspector.get_columns('app_settings')}
|
||||
|
||||
new_columns = {
|
||||
'recommendations_filter': 'TEXT DEFAULT "all"',
|
||||
'releases_filter': 'TEXT DEFAULT "all"',
|
||||
'anime_enabled': 'BOOLEAN DEFAULT 1',
|
||||
'series_enabled': 'BOOLEAN DEFAULT 1',
|
||||
'download_dir': 'TEXT DEFAULT "downloads"',
|
||||
}
|
||||
|
||||
# Add is_admin to users table if missing
|
||||
if 'users' in inspector.get_table_names():
|
||||
user_cols = {col['name'] for col in inspector.get_columns('users')}
|
||||
if 'is_admin' not in user_cols:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text('ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0'))
|
||||
conn.commit()
|
||||
|
||||
with engine.connect() as conn:
|
||||
for col_name, col_def in new_columns.items():
|
||||
if col_name not in existing:
|
||||
conn.execute(text(f'ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
"""Dependency for getting a database session"""
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
+128
-4
@@ -2,6 +2,7 @@ import asyncio
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
@@ -124,13 +125,18 @@ class DownloadManager:
|
||||
downloader = get_downloader(task.url)
|
||||
|
||||
# Extract episode title from pipe-separated URL if present
|
||||
# Format: video_url|anime_page_url|episode_title
|
||||
# Format: video_url1|video_url2|...|anime_page_url|episode_title
|
||||
target_filename = None
|
||||
if '|' in task.url:
|
||||
parts = task.url.split('|')
|
||||
if len(parts) >= 3:
|
||||
target_filename = parts[2].strip()
|
||||
logger.debug(f"Extracted target filename from pipe: {target_filename}")
|
||||
# Last part is episode title, second to last is anime page URL
|
||||
if len(parts) >= 2:
|
||||
# Get the last part as episode title
|
||||
potential_title = parts[-1].strip()
|
||||
# Only use it if it looks like a title (not a URL)
|
||||
if potential_title and not potential_title.startswith('http'):
|
||||
target_filename = potential_title
|
||||
logger.debug(f"Extracted target filename from pipe: {target_filename}")
|
||||
|
||||
download_url, filename = await downloader.get_download_link(task.url, target_filename)
|
||||
|
||||
@@ -146,6 +152,15 @@ class DownloadManager:
|
||||
|
||||
task.file_path = str(self.download_dir / task.filename)
|
||||
|
||||
# Check if URL is HLS/m3u8 - use ffmpeg to download
|
||||
if download_url.endswith('.m3u8') or '.m3u8?' in download_url:
|
||||
logger.info(f"Detected HLS stream, using ffmpeg to download: {task.filename}")
|
||||
success = await self._download_hls(download_url, task)
|
||||
if success:
|
||||
return
|
||||
# If ffmpeg fails, fall through to regular download attempt
|
||||
logger.warning("ffmpeg download failed, trying regular download")
|
||||
|
||||
# Check if download_url is a local file path (VidMoly M3U8 pre-download)
|
||||
if os.path.exists(download_url):
|
||||
logger.info(f"VidMoly already downloaded file to: {download_url}")
|
||||
@@ -279,3 +294,112 @@ class DownloadManager:
|
||||
# Log completion info
|
||||
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
|
||||
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")
|
||||
|
||||
async def _download_hls(self, m3u8_url: str, task: DownloadTask) -> bool:
|
||||
"""Download HLS/m3u8 stream using ffmpeg"""
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
try:
|
||||
# Build ffmpeg command for HLS download
|
||||
cmd = [
|
||||
'ffmpeg',
|
||||
'-y', # Overwrite output file
|
||||
'-headers', 'Referer: https://lpayer.embed4me.com/',
|
||||
'-i', m3u8_url,
|
||||
'-c', 'copy', # Stream copy (no re-encoding)
|
||||
'-bsf:a', 'aac_adtstoasc', # Fix AAC streams
|
||||
'-progress', 'pipe:1', # Output progress to stdout
|
||||
task.file_path
|
||||
]
|
||||
|
||||
logger.info(f"Starting ffmpeg HLS download: {task.filename}")
|
||||
|
||||
# Run ffmpeg as subprocess
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
# Read progress from ffmpeg
|
||||
while True:
|
||||
if task.status == DownloadStatus.CANCELLED:
|
||||
process.terminate()
|
||||
return False
|
||||
|
||||
if task.status == DownloadStatus.PAUSED:
|
||||
process.terminate()
|
||||
return False
|
||||
|
||||
try:
|
||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=1.0)
|
||||
if not line:
|
||||
break
|
||||
|
||||
line = line.decode('utf-8', errors='ignore').strip()
|
||||
|
||||
# Parse ffmpeg progress output
|
||||
if line.startswith('out_time_ms='):
|
||||
try:
|
||||
out_time_us = int(line.split('=')[1])
|
||||
out_time_sec = out_time_us / 1_000_000
|
||||
|
||||
# Update progress based on duration (if known)
|
||||
# ffmpeg doesn't always report total duration
|
||||
task.downloaded_bytes = int(out_time_sec * 1000000) # Approximate
|
||||
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
if elapsed > 0:
|
||||
task.speed = task.downloaded_bytes / elapsed
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
elif line.startswith('total_size='):
|
||||
try:
|
||||
size = int(line.split('=')[1])
|
||||
if size > 0:
|
||||
task.total_bytes = size
|
||||
if task.downloaded_bytes > 0:
|
||||
task.progress = (task.downloaded_bytes / size) * 100
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Check if process is still running
|
||||
if process.returncode is not None:
|
||||
break
|
||||
continue
|
||||
|
||||
# Wait for process to complete
|
||||
await process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
# Check if file was created
|
||||
if os.path.exists(task.file_path):
|
||||
file_size = os.path.getsize(task.file_path)
|
||||
logger.info(f"✅ HLS download complete: {task.filename} ({file_size / (1024*1024):.2f} MB)")
|
||||
task.status = DownloadStatus.COMPLETED
|
||||
task.progress = 100.0
|
||||
task.downloaded_bytes = file_size
|
||||
task.total_bytes = file_size
|
||||
task.completed_at = datetime.now()
|
||||
return True
|
||||
else:
|
||||
logger.error(f"HLS download failed: file not created")
|
||||
return False
|
||||
else:
|
||||
# Get stderr for error message
|
||||
stderr = await process.stderr.read()
|
||||
error_msg = stderr.decode('utf-8', errors='ignore')
|
||||
logger.error(f"ffmpeg failed with code {process.returncode}: {error_msg[:500]}")
|
||||
return False
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("ffmpeg not found - cannot download HLS streams")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"HLS download error: {e}")
|
||||
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
|
||||
@@ -17,14 +17,14 @@ from .anime_sites import (
|
||||
BaseAnimeSite,
|
||||
get_anime_site,
|
||||
AnimeSamaDownloader,
|
||||
NekoSamaDownloader,
|
||||
AnimeUltimeDownloader,
|
||||
VostfreeDownloader
|
||||
)
|
||||
from .series_sites import (
|
||||
BaseSeriesSite,
|
||||
get_series_site,
|
||||
FS7Downloader
|
||||
FS7Downloader,
|
||||
ZoneTelechargementDownloader
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +67,3 @@ class GenericDownloader(BaseDownloader):
|
||||
# Just return the URL as-is
|
||||
filename = target_filename or url.split('/')[-1] or "download"
|
||||
return url, filename
|
||||
# Just return the URL as-is
|
||||
filename = url.split('/')[-1] or "download"
|
||||
return url, filename
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Anime Sites (app/downloaders/anime_sites/)
|
||||
|
||||
## OVERVIEW
|
||||
Handlers for French anime streaming catalogs that provide metadata and episode listings, delegating actual video extraction to video player handlers.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `base.py` | Abstract `BaseAnimeSite` class defining the interface |
|
||||
| `animesama.py` | Primary provider — dynamic domain switching, multiple video player extraction |
|
||||
| `nekosama.py` | Neko-Sama / Gupy integration (metadata-only, no direct downloads) |
|
||||
| `animeultime.py` | Anime-Ultime catalog handler |
|
||||
| `vostfree.py` | Vostfree catalog handler |
|
||||
| `frenchmanga.py` | French-Manga catalog handler |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
**Interface contract** — each site implements from `BaseAnimeSite`:
|
||||
- `can_handle(url)` — URL pattern matching
|
||||
- `search_anime(query, lang)` → `[{title, url, cover_image}]`
|
||||
- `get_episodes(anime_url, lang)` → `[{episode_number, url, title, host}]`
|
||||
- `get_anime_metadata(anime_url)` → `{synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status}`
|
||||
- `get_download_link(url)` → `(video_player_url, filename)`
|
||||
|
||||
**Key patterns**:
|
||||
- Pipe-separated URLs: `video_url|anime_page_url|episode_title`
|
||||
- Language param: `lang="vostfr"` or `"vf"`
|
||||
- Video player delegation: returns player URLs (vidmoly, sendvid, etc.), NOT direct downloads
|
||||
- Filename format: `{anime_name} - S{season} - {episode}.mp4`
|
||||
- Browser UA + referer headers required
|
||||
|
||||
**Domain detection**: `AnimeSamaDownloader` fetches current domain from `anime-sama.pw` dynamically. Uses fallback chain for video extraction.
|
||||
|
||||
**Error handling**: Raise `Exception` with descriptive message. Log at `debug` for expected failures, `error` for unexpected. Validate URLs with `_test_video_url()` before returning.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Do NOT return direct download URLs from anime sites — return player URLs
|
||||
- Do NOT skip URL validation — use `_test_video_url()`
|
||||
- 5 empty `except:` blocks in `animesama.py` — known tech debt, silently swallow failures
|
||||
@@ -2,7 +2,6 @@
|
||||
from .base import BaseAnimeSite
|
||||
# Import all anime site downloaders
|
||||
from .animesama import AnimeSamaDownloader
|
||||
from .nekosama import NekoSamaDownloader
|
||||
from .animeultime import AnimeUltimeDownloader
|
||||
from .vostfree import VostfreeDownloader
|
||||
from .frenchmanga import FrenchMangaDownloader
|
||||
@@ -10,7 +9,6 @@ from .frenchmanga import FrenchMangaDownloader
|
||||
__all__ = [
|
||||
"BaseAnimeSite",
|
||||
"AnimeSamaDownloader",
|
||||
"NekoSamaDownloader",
|
||||
"AnimeUltimeDownloader",
|
||||
"VostfreeDownloader",
|
||||
"FrenchMangaDownloader",
|
||||
@@ -22,7 +20,6 @@ def get_anime_site(url: str) -> BaseAnimeSite:
|
||||
sites = [
|
||||
AnimeSamaDownloader(),
|
||||
AnimeUltimeDownloader(),
|
||||
NekoSamaDownloader(),
|
||||
VostfreeDownloader(),
|
||||
FrenchMangaDownloader(),
|
||||
]
|
||||
|
||||
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"]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.id = "anime-ultime"
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||
|
||||
@@ -24,58 +28,79 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
||||
final_url = str(response.url)
|
||||
|
||||
# Parse the page
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
# Method 0: Look for og:video meta tag (most reliable for anime-ultime)
|
||||
og_video = soup.find('meta', property='og:video')
|
||||
if og_video and og_video.get('content'):
|
||||
video_url = og_video['content']
|
||||
if video_url.endswith('.mp4'):
|
||||
og_video = soup.find("meta", property="og:video")
|
||||
if og_video and og_video.get("content"):
|
||||
video_url = og_video["content"]
|
||||
if video_url.endswith(".mp4"):
|
||||
filename = self._generate_filename(final_url)
|
||||
print(f"[ANIME-ULTIME] Found og:video link: {video_url}")
|
||||
return video_url, filename
|
||||
|
||||
# Method 1: Look for direct download links (DDL)
|
||||
# Anime-Ultime often uses links to file hosts
|
||||
download_links = soup.find_all('a', href=True)
|
||||
download_links = soup.find_all("a", href=True)
|
||||
for link in download_links:
|
||||
href = link['href']
|
||||
href = link["href"]
|
||||
text = link.get_text().lower()
|
||||
|
||||
# Look for download buttons/links
|
||||
if any(keyword in text for keyword in ['télécharger', 'download', 'ddl', 'mega', 'google', 'drive']):
|
||||
if any(
|
||||
keyword in text
|
||||
for keyword in [
|
||||
"télécharger",
|
||||
"download",
|
||||
"ddl",
|
||||
"mega",
|
||||
"google",
|
||||
"drive",
|
||||
]
|
||||
):
|
||||
# Check if it's a direct link or to a file host
|
||||
if any(host in href.lower() for host in ['mega.nz', 'drive.google.com', 'uptobox.com', '1fichier.com']):
|
||||
if any(
|
||||
host in href.lower()
|
||||
for host in [
|
||||
"mega.nz",
|
||||
"drive.google.com",
|
||||
"uptobox.com",
|
||||
"1fichier.com",
|
||||
]
|
||||
):
|
||||
filename = self._generate_filename(final_url)
|
||||
return href, filename
|
||||
|
||||
# Method 2: Look for iframe with video player
|
||||
iframes = soup.find_all('iframe')
|
||||
iframes = soup.find_all("iframe")
|
||||
for iframe in iframes:
|
||||
src = iframe.get('src', '')
|
||||
if src and any(provider in src for provider in ['video', 'player', 'stream', 'play']):
|
||||
if src.startswith('http'):
|
||||
src = iframe.get("src", "")
|
||||
if src and any(
|
||||
provider in src
|
||||
for provider in ["video", "player", "stream", "play"]
|
||||
):
|
||||
if src.startswith("http"):
|
||||
filename = self._generate_filename(final_url)
|
||||
return src, filename
|
||||
|
||||
# Method 3: Look for video tags
|
||||
videos = soup.find_all('video')
|
||||
videos = soup.find_all("video")
|
||||
for video in videos:
|
||||
src = video.get('src', '')
|
||||
src = video.get("src", "")
|
||||
if src:
|
||||
filename = self._generate_filename(final_url)
|
||||
return src, filename
|
||||
|
||||
# Check source tags
|
||||
sources = video.find_all('source')
|
||||
sources = video.find_all("source")
|
||||
for source in sources:
|
||||
src = source.get('src', '')
|
||||
src = source.get("src", "")
|
||||
if src:
|
||||
filename = self._generate_filename(final_url)
|
||||
return src, filename
|
||||
|
||||
# Method 4: Look in scripts for video URLs
|
||||
scripts = soup.find_all('script')
|
||||
scripts = soup.find_all("script")
|
||||
for script in scripts:
|
||||
if script.string:
|
||||
# Look for common video patterns
|
||||
@@ -91,26 +116,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
||||
matches = re.findall(pattern, script.string)
|
||||
for match in matches:
|
||||
# Clean up escaped characters
|
||||
match = match.replace('\\/', '/').replace('\\', '')
|
||||
if any(ext in match for ext in ['mp4', 'm3u8', 'mkv']):
|
||||
match = match.replace("\\/", "/").replace("\\", "")
|
||||
if any(ext in match for ext in ["mp4", "m3u8", "mkv"]):
|
||||
filename = self._generate_filename(final_url)
|
||||
return match, filename
|
||||
|
||||
# Look for anime-ultime specific patterns
|
||||
# They sometimes store links in JavaScript variables
|
||||
ddl_match = re.search(r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string)
|
||||
ddl_match = re.search(
|
||||
r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string
|
||||
)
|
||||
if ddl_match:
|
||||
ddl_url = ddl_match.group(1)
|
||||
if ddl_url.startswith('http'):
|
||||
if ddl_url.startswith("http"):
|
||||
filename = self._generate_filename(final_url)
|
||||
return ddl_url, filename
|
||||
|
||||
# Method 5: Look for links with specific classes or IDs
|
||||
# Anime-Ultime might use specific class names for download links
|
||||
potential_links = soup.find_all('a', class_=re.compile(r'download|ddl|episode', re.I))
|
||||
potential_links = soup.find_all(
|
||||
"a", class_=re.compile(r"download|ddl|episode", re.I)
|
||||
)
|
||||
for link in potential_links:
|
||||
href = link.get('href', '')
|
||||
if href and href.startswith('http'):
|
||||
href = link.get("href", "")
|
||||
if href and href.startswith("http"):
|
||||
filename = self._generate_filename(final_url)
|
||||
return href, filename
|
||||
|
||||
@@ -132,36 +161,38 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
||||
episode = "01"
|
||||
|
||||
# Format: info-0-1/EPISODE_ID or info-0-1/EPISODE_ID/NAME-EP-vostfr
|
||||
if 'info-0-1/' in url:
|
||||
if "info-0-1/" in url:
|
||||
# Extract episode ID
|
||||
ep_match = re.search(r'info-0-1/(\d+)', url)
|
||||
ep_match = re.search(r"info-0-1/(\d+)", url)
|
||||
if ep_match:
|
||||
ep_id = ep_match.group(1)
|
||||
|
||||
# Try to get anime name from URL path
|
||||
name_match = re.search(r'info-0-1/\d+/([^/]+)', url)
|
||||
name_match = re.search(r"info-0-1/\d+/([^/]+)", url)
|
||||
if name_match:
|
||||
raw_name = name_match.group(1)
|
||||
# Extract episode number
|
||||
ep_num_match = re.search(r'-(\d+)-vostfr$', raw_name, re.I)
|
||||
ep_num_match = re.search(r"-(\d+)-vostfr$", raw_name, re.I)
|
||||
if ep_num_match:
|
||||
episode = ep_num_match.group(1).zfill(2)
|
||||
# Remove episode number and suffix from name
|
||||
anime_name = re.sub(r'-\d+-vostfr$', '', raw_name, flags=re.I).replace('-', ' ')
|
||||
anime_name = re.sub(
|
||||
r"-\d+-vostfr$", "", raw_name, flags=re.I
|
||||
).replace("-", " ")
|
||||
else:
|
||||
# Just use the ID
|
||||
anime_name = f"Episode {ep_id}"
|
||||
else:
|
||||
anime_name = f"Episode {ep_id}"
|
||||
|
||||
elif 'file-0-1/' in url:
|
||||
elif "file-0-1/" in url:
|
||||
# Extract from file-0-1/ID-NAME format
|
||||
file_match = re.search(r'file-0-1/\d+-(.+)$', url)
|
||||
file_match = re.search(r"file-0-1/\d+-(.+)$", url)
|
||||
if file_match:
|
||||
anime_name = file_match.group(1).replace('-', ' ')
|
||||
anime_name = file_match.group(1).replace("-", " ")
|
||||
|
||||
# Sanitize filename
|
||||
anime_name = anime_name.replace('/', ' ').strip()
|
||||
anime_name = anime_name.replace("/", " ").strip()
|
||||
filename = f"{anime_name} - Episode {episode}.mp4"
|
||||
return filename.title()
|
||||
|
||||
@@ -173,30 +204,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
||||
try:
|
||||
print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}")
|
||||
response = await self.client.get(anime_url)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
metadata = {
|
||||
'synopsis': None,
|
||||
'genres': [],
|
||||
'rating': None,
|
||||
'release_year': None,
|
||||
'studio': None,
|
||||
'poster_image': None,
|
||||
'banner_image': None,
|
||||
'total_episodes': None,
|
||||
'status': None,
|
||||
'alternative_titles': []
|
||||
"synopsis": None,
|
||||
"genres": [],
|
||||
"rating": None,
|
||||
"release_year": None,
|
||||
"studio": None,
|
||||
"poster_image": None,
|
||||
"banner_image": None,
|
||||
"total_episodes": None,
|
||||
"status": None,
|
||||
"alternative_titles": [],
|
||||
}
|
||||
|
||||
# Extract synopsis
|
||||
synopsis_selectors = [
|
||||
'div.synopsis',
|
||||
'div.description',
|
||||
"div.synopsis",
|
||||
"div.description",
|
||||
'div[class*="synopsis"]',
|
||||
'div[class*="synopsis"]',
|
||||
'p.synopsis',
|
||||
'.info',
|
||||
'div.texte'
|
||||
"p.synopsis",
|
||||
".info",
|
||||
"div.texte",
|
||||
]
|
||||
|
||||
for selector in synopsis_selectors:
|
||||
@@ -204,68 +235,73 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
||||
if synopsis_elem:
|
||||
synopsis = synopsis_elem.get_text(strip=True)
|
||||
if len(synopsis) > 50:
|
||||
metadata['synopsis'] = synopsis
|
||||
metadata["synopsis"] = synopsis
|
||||
break
|
||||
|
||||
# Extract genres from meta tags and page content
|
||||
page_text = soup.get_text()
|
||||
|
||||
# Look for genre in meta tags
|
||||
genre_meta = soup.find('meta', property='genre') or soup.find('meta', attrs={'name': 'genre'})
|
||||
genre_meta = soup.find("meta", property="genre") or soup.find(
|
||||
"meta", attrs={"name": "genre"}
|
||||
)
|
||||
if genre_meta:
|
||||
genres_text = genre_meta.get('content', '')
|
||||
genres_text = genre_meta.get("content", "")
|
||||
if genres_text:
|
||||
metadata['genres'] = [g.strip() for g in genres_text.split(',')]
|
||||
metadata["genres"] = [g.strip() for g in genres_text.split(",")]
|
||||
|
||||
# Try to find genre links
|
||||
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type|cat', re.I))
|
||||
genre_links = soup.find_all(
|
||||
"a", href=re.compile(r"genre|tag|type|cat", re.I)
|
||||
)
|
||||
if genre_links:
|
||||
for link in genre_links[:5]:
|
||||
genre = link.get_text(strip=True)
|
||||
if genre and genre not in metadata['genres']:
|
||||
metadata['genres'].append(genre)
|
||||
if genre and genre not in metadata["genres"]:
|
||||
metadata["genres"].append(genre)
|
||||
|
||||
# Extract rating
|
||||
rating_selectors = [
|
||||
'span.rating',
|
||||
'div.rating',
|
||||
'span.score',
|
||||
'div.note',
|
||||
'.rating'
|
||||
"span.rating",
|
||||
"div.rating",
|
||||
"span.score",
|
||||
"div.note",
|
||||
".rating",
|
||||
]
|
||||
|
||||
for selector in rating_selectors:
|
||||
rating_elem = soup.select_one(selector)
|
||||
if rating_elem:
|
||||
rating_text = rating_elem.get_text(strip=True)
|
||||
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
|
||||
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
|
||||
if rating_match:
|
||||
metadata['rating'] = f"{rating_match.group(1)}/10"
|
||||
metadata["rating"] = f"{rating_match.group(1)}/10"
|
||||
break
|
||||
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*5', rating_text)
|
||||
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*5", rating_text)
|
||||
if rating_match:
|
||||
rating_val = float(rating_match.group(1)) * 2
|
||||
metadata['rating'] = f"{rating_val:.1f}/10"
|
||||
metadata["rating"] = f"{rating_val:.1f}/10"
|
||||
break
|
||||
|
||||
# Extract release year
|
||||
year_match = re.search(r'\b(19\d{2}|20\d{2})\b', page_text)
|
||||
year_match = re.search(r"\b(19\d{2}|20\d{2})\b", page_text)
|
||||
if year_match:
|
||||
import datetime
|
||||
|
||||
current_year = datetime.datetime.now().year + 2
|
||||
year = int(year_match.group(1))
|
||||
if 1950 <= year <= current_year:
|
||||
metadata['release_year'] = year
|
||||
metadata["release_year"] = year
|
||||
|
||||
# Extract poster image from og:image
|
||||
og_image = soup.find('meta', property='og:image')
|
||||
og_image = soup.find("meta", property="og:image")
|
||||
if og_image:
|
||||
metadata['poster_image'] = og_image.get('content')
|
||||
metadata["poster_image"] = og_image.get("content")
|
||||
|
||||
# Extract total episodes
|
||||
episodes_count = len(await self.get_episodes(anime_url))
|
||||
if episodes_count > 0:
|
||||
metadata['total_episodes'] = episodes_count
|
||||
metadata["total_episodes"] = episodes_count
|
||||
|
||||
print(f"[ANIME-ULTIME] Extracted metadata: {metadata}")
|
||||
return metadata
|
||||
@@ -274,7 +310,9 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
||||
print(f"[ANIME-ULTIME] Error extracting metadata: {e}")
|
||||
return {}
|
||||
|
||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
||||
async def search_anime(
|
||||
self, query: str, lang: str = "vostfr", include_metadata: bool = False
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Search for anime on anime-ultime
|
||||
Returns list of anime with title, url, and cover image
|
||||
@@ -286,27 +324,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
||||
"""
|
||||
try:
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...")
|
||||
|
||||
# Anime-Ultime uses POST for search
|
||||
search_url = "https://www.anime-ultime.net/search-0-1"
|
||||
|
||||
response = await self.client.post(search_url, data={'search': query})
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
response = await self.client.post(search_url, data={"search": query})
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
elapsed = time.time() - start
|
||||
print(f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s")
|
||||
print(
|
||||
f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s"
|
||||
)
|
||||
|
||||
results = []
|
||||
|
||||
# Look for search result links - better parsing
|
||||
# Search results use file-0-1/ pattern, not info-
|
||||
search_results = soup.find_all('a', href=re.compile(r'file-0-1/'))
|
||||
search_results = soup.find_all("a", href=re.compile(r"file-0-1/"))
|
||||
|
||||
seen_urls = set()
|
||||
for result in search_results[:10]: # Limit to 10 results
|
||||
href = result.get('href', '')
|
||||
href = result.get("href", "")
|
||||
raw_title = result.get_text().strip()
|
||||
|
||||
# Skip if no href
|
||||
@@ -322,40 +363,44 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
||||
better_title = raw_title
|
||||
|
||||
# If raw_title is just "Télécharger" or similar, try to find better title
|
||||
if len(raw_title) < 5 or raw_title.lower() in ['télécharger', 'download', 'ddl']:
|
||||
if len(raw_title) < 5 or raw_title.lower() in [
|
||||
"télécharger",
|
||||
"download",
|
||||
"ddl",
|
||||
]:
|
||||
# Try to extract from URL (file-0-1/ID-Title format)
|
||||
url_match = re.search(r'file-0-1/\d+-(.+)$', href)
|
||||
url_match = re.search(r"file-0-1/\d+-(.+)$", href)
|
||||
if url_match:
|
||||
better_title = url_match.group(1).replace('-', ' ').title()
|
||||
better_title = url_match.group(1).replace("-", " ").title()
|
||||
|
||||
# If still no good title, look at parent/row elements
|
||||
if len(better_title) < 5:
|
||||
# Check parent row (table structure)
|
||||
row = result.find_parent(['tr', 'td', 'div'])
|
||||
row = result.find_parent(["tr", "td", "div"])
|
||||
if row:
|
||||
# Look for text in the row that's not the link text
|
||||
row_text = row.get_text().strip()
|
||||
# Remove the link text from row text
|
||||
if raw_title in row_text:
|
||||
row_text = row_text.replace(raw_title, '').strip()
|
||||
row_text = row_text.replace(raw_title, "").strip()
|
||||
if len(row_text) > 5 and len(row_text) < 100:
|
||||
better_title = row_text
|
||||
|
||||
# Make URL absolute
|
||||
if not href.startswith('http'):
|
||||
if not href.startswith("http"):
|
||||
href = urljoin("https://www.anime-ultime.net/", href)
|
||||
|
||||
result_item = {
|
||||
'title': better_title,
|
||||
'url': href,
|
||||
'type': 'search_result',
|
||||
'metadata': None
|
||||
"title": better_title,
|
||||
"url": href,
|
||||
"type": "search_result",
|
||||
"metadata": None,
|
||||
}
|
||||
|
||||
# Fetch metadata if requested
|
||||
if include_metadata:
|
||||
metadata = await self.get_anime_metadata(href)
|
||||
result_item['metadata'] = metadata
|
||||
result_item["metadata"] = metadata
|
||||
|
||||
results.append(result_item)
|
||||
|
||||
@@ -373,27 +418,27 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(anime_url)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
episodes = []
|
||||
|
||||
# Look for episode links - anime-ultime uses info-XXXXX-Name-XX-vostfr format
|
||||
# The URL pattern is info-0-1/ID-Anime-Name-XX-vostfr where XX is episode number
|
||||
episode_links = soup.find_all('a', href=re.compile(r'info-0-1/\d+'))
|
||||
episode_links = soup.find_all("a", href=re.compile(r"info-0-1/\d+"))
|
||||
|
||||
for link in episode_links:
|
||||
href = link.get('href', '')
|
||||
href = link.get("href", "")
|
||||
text = link.get_text().strip()
|
||||
|
||||
# Extract episode number from URL pattern
|
||||
# Matches: info-0-1/30200/Naruto-OAV-01-vostfr
|
||||
match = re.search(r'-(\d+)-vostfr$', href, re.I)
|
||||
match = re.search(r"-(\d+)-vostfr$", href, re.I)
|
||||
if not match:
|
||||
# Try other patterns
|
||||
match = re.search(r'Episode[-\s]?(\d+)', href, re.I)
|
||||
match = re.search(r"Episode[-\s]?(\d+)", href, re.I)
|
||||
if not match:
|
||||
# Try to extract from text
|
||||
match = re.search(r'(\d+)', text)
|
||||
match = re.search(r"(\d+)", text)
|
||||
|
||||
if match:
|
||||
episode_num = match.group(1).zfill(2) # Pad with zero
|
||||
@@ -401,32 +446,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
||||
# Extract the episode ID from href and build correct URL
|
||||
# href might be "info-0-1/30200" or "info-0-1/30200/..."
|
||||
# We need: https://www.anime-ultime.net/info-0-1/30200
|
||||
ep_id_match = re.search(r'info-0-1/(\d+)', href)
|
||||
ep_id_match = re.search(r"info-0-1/(\d+)", href)
|
||||
if ep_id_match:
|
||||
ep_id = ep_id_match.group(1)
|
||||
# Build the correct episode URL
|
||||
episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}"
|
||||
else:
|
||||
# Fallback to making URL absolute
|
||||
if not href.startswith('http'):
|
||||
if not href.startswith("http"):
|
||||
href = urljoin(anime_url, href)
|
||||
episode_url = href
|
||||
|
||||
episodes.append({
|
||||
'episode': episode_num,
|
||||
'url': episode_url,
|
||||
'title': text
|
||||
})
|
||||
episodes.append(
|
||||
{"episode": episode_num, "url": episode_url, "title": text}
|
||||
)
|
||||
|
||||
# Remove duplicates and sort
|
||||
seen = set()
|
||||
unique_episodes = []
|
||||
for ep in episodes:
|
||||
if ep['episode'] not in seen:
|
||||
seen.add(ep['episode'])
|
||||
if ep["episode"] not in seen:
|
||||
seen.add(ep["episode"])
|
||||
unique_episodes.append(ep)
|
||||
|
||||
unique_episodes.sort(key=lambda x: int(x['episode']))
|
||||
unique_episodes.sort(key=lambda x: int(x["episode"]))
|
||||
|
||||
return unique_episodes
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""French-Manga.net anime streaming site downloader"""
|
||||
|
||||
from .base import BaseAnimeSite
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
@@ -17,11 +18,12 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
||||
"french-manga.net",
|
||||
"w16.french-manga.net",
|
||||
"w15.french-manga.net",
|
||||
"www.french-manga.net"
|
||||
"www.french-manga.net",
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.id = "french-manga"
|
||||
self.base_url = "https://w16.french-manga.net"
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
@@ -29,9 +31,7 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||
|
||||
async def search_anime(
|
||||
self,
|
||||
query: str,
|
||||
lang: str = "vostfr"
|
||||
self, query: str, lang: str = "vostfr"
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Search for anime on French-Manga.
|
||||
@@ -47,46 +47,50 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
||||
# French-Manga uses a search endpoint
|
||||
search_url = f"{self.base_url}/index.php?do=search"
|
||||
params = {
|
||||
'do': 'search',
|
||||
'subaction': 'search',
|
||||
'story': query,
|
||||
'x': '0',
|
||||
'y': '0'
|
||||
"do": "search",
|
||||
"subaction": "search",
|
||||
"story": query,
|
||||
"x": "0",
|
||||
"y": "0",
|
||||
}
|
||||
|
||||
response = await self.client.post(search_url, data=params)
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
results = []
|
||||
|
||||
# Look for search results in article or story classes
|
||||
for item in soup.find_all('article', class_=lambda x: x and 'story' in x.lower()):
|
||||
title_elem = item.find(['h2', 'h3', 'h4'])
|
||||
link_elem = item.find('a', href=True)
|
||||
img_elem = item.find('img')
|
||||
for item in soup.find_all(
|
||||
"article", class_=lambda x: x and "story" in x.lower()
|
||||
):
|
||||
title_elem = item.find(["h2", "h3", "h4"])
|
||||
link_elem = item.find("a", href=True)
|
||||
img_elem = item.find("img")
|
||||
|
||||
if title_elem and link_elem:
|
||||
title = title_elem.get_text(strip=True)
|
||||
url = link_elem['href']
|
||||
url = link_elem["href"]
|
||||
|
||||
# Ensure absolute URL
|
||||
if url.startswith('/'):
|
||||
if url.startswith("/"):
|
||||
url = self.base_url + url
|
||||
|
||||
cover_image = ""
|
||||
if img_elem and img_elem.get('src'):
|
||||
cover_image = img_elem['src']
|
||||
if cover_image.startswith('/'):
|
||||
if img_elem and img_elem.get("src"):
|
||||
cover_image = img_elem["src"]
|
||||
if cover_image.startswith("/"):
|
||||
cover_image = self.base_url + cover_image
|
||||
|
||||
results.append({
|
||||
'title': title,
|
||||
'url': url,
|
||||
'cover_image': cover_image,
|
||||
'lang': lang
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"url": url,
|
||||
"cover_image": cover_image,
|
||||
"lang": lang,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} anime results for query: {query}")
|
||||
return results
|
||||
@@ -96,9 +100,7 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
||||
return []
|
||||
|
||||
async def get_episodes(
|
||||
self,
|
||||
anime_url: str,
|
||||
lang: str = "vostfr"
|
||||
self, anime_url: str, lang: str = "vostfr"
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get episode list for an anime.
|
||||
@@ -115,34 +117,36 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
episodes = []
|
||||
|
||||
# Look for episode links (typically in a list or table)
|
||||
# French-Manga usually has episode links in <a> tags with episode numbers
|
||||
for link in soup.find_all('a', href=True):
|
||||
href = link['href']
|
||||
for link in soup.find_all("a", href=True):
|
||||
href = link["href"]
|
||||
text = link.get_text(strip=True)
|
||||
|
||||
# Pattern: Episode links usually contain "episode" or numbers
|
||||
if re.search(r'episode?\s*\d+', text.lower()):
|
||||
episode_num = re.search(r'(\d+)', text)
|
||||
if re.search(r"episode?\s*\d+", text.lower()):
|
||||
episode_num = re.search(r"(\d+)", text)
|
||||
if episode_num:
|
||||
episode_number = int(episode_num.group(1))
|
||||
|
||||
# Ensure absolute URL
|
||||
if href.startswith('/'):
|
||||
if href.startswith("/"):
|
||||
href = self.base_url + href
|
||||
|
||||
episodes.append({
|
||||
'episode_number': episode_number,
|
||||
'url': href,
|
||||
'title': text,
|
||||
'host': 'french-manga'
|
||||
})
|
||||
episodes.append(
|
||||
{
|
||||
"episode_number": episode_number,
|
||||
"url": href,
|
||||
"title": text,
|
||||
"host": "french-manga",
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by episode number
|
||||
episodes.sort(key=lambda x: x['episode_number'])
|
||||
episodes.sort(key=lambda x: x["episode_number"])
|
||||
|
||||
logger.info(f"Found {len(episodes)} episodes for {anime_url}")
|
||||
return episodes
|
||||
@@ -166,31 +170,33 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
# Extract title
|
||||
title = ""
|
||||
title_elem = soup.find('h1') or soup.find('h2', class_='title')
|
||||
title_elem = soup.find("h1") or soup.find("h2", class_="title")
|
||||
if title_elem:
|
||||
title = title_elem.get_text(strip=True)
|
||||
|
||||
# Extract synopsis
|
||||
synopsis = ""
|
||||
synopsis_elem = soup.find('div', class_=lambda x: x and 'story' in x.lower())
|
||||
synopsis_elem = soup.find(
|
||||
"div", class_=lambda x: x and "story" in x.lower()
|
||||
)
|
||||
if synopsis_elem:
|
||||
synopsis = synopsis_elem.get_text(strip=True)
|
||||
|
||||
# Extract cover image
|
||||
poster_image = ""
|
||||
img_elem = soup.find('img', class_=lambda x: x and 'poster' in x.lower())
|
||||
if img_elem and img_elem.get('src'):
|
||||
poster_image = img_elem['src']
|
||||
if poster_image.startswith('/'):
|
||||
img_elem = soup.find("img", class_=lambda x: x and "poster" in x.lower())
|
||||
if img_elem and img_elem.get("src"):
|
||||
poster_image = img_elem["src"]
|
||||
if poster_image.startswith("/"):
|
||||
poster_image = self.base_url + poster_image
|
||||
|
||||
# Extract genres
|
||||
genres = []
|
||||
genre_links = soup.find_all('a', href=re.compile(r'/xfsearch/.*genre/'))
|
||||
genre_links = soup.find_all("a", href=re.compile(r"/xfsearch/.*genre/"))
|
||||
for link in genre_links[:10]: # Limit to 10 genres
|
||||
genre = link.get_text(strip=True)
|
||||
if genre:
|
||||
@@ -198,36 +204,38 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
||||
|
||||
# Extract rating (if available)
|
||||
rating = ""
|
||||
rating_elem = soup.find(['span', 'div'], class_=lambda x: x and 'rating' in x.lower())
|
||||
rating_elem = soup.find(
|
||||
["span", "div"], class_=lambda x: x and "rating" in x.lower()
|
||||
)
|
||||
if rating_elem:
|
||||
rating = rating_elem.get_text(strip=True)
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'synopsis': synopsis,
|
||||
'genres': genres,
|
||||
'rating': rating,
|
||||
'release_year': '',
|
||||
'studio': '',
|
||||
'poster_image': poster_image,
|
||||
'total_episodes': len(await self.get_episodes(anime_url)),
|
||||
'status': '',
|
||||
'languages': ['vf', 'vostfr']
|
||||
"title": title,
|
||||
"synopsis": synopsis,
|
||||
"genres": genres,
|
||||
"rating": rating,
|
||||
"release_year": "",
|
||||
"studio": "",
|
||||
"poster_image": poster_image,
|
||||
"total_episodes": len(await self.get_episodes(anime_url)),
|
||||
"status": "",
|
||||
"languages": ["vf", "vostfr"],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting anime metadata: {e}")
|
||||
return {
|
||||
'title': '',
|
||||
'synopsis': '',
|
||||
'genres': [],
|
||||
'rating': '',
|
||||
'release_year': '',
|
||||
'studio': '',
|
||||
'poster_image': '',
|
||||
'total_episodes': 0,
|
||||
'status': '',
|
||||
'languages': ['vf', 'vostfr']
|
||||
"title": "",
|
||||
"synopsis": "",
|
||||
"genres": [],
|
||||
"rating": "",
|
||||
"release_year": "",
|
||||
"studio": "",
|
||||
"poster_image": "",
|
||||
"total_episodes": 0,
|
||||
"status": "",
|
||||
"languages": ["vf", "vostfr"],
|
||||
}
|
||||
|
||||
async def get_download_link(self, url: str) -> tuple[str, str]:
|
||||
@@ -248,20 +256,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
# Look for iframe or video player
|
||||
iframe = soup.find('iframe', src=True)
|
||||
iframe = soup.find("iframe", src=True)
|
||||
if iframe:
|
||||
video_url = iframe['src']
|
||||
video_url = iframe["src"]
|
||||
else:
|
||||
# Look for video tag directly
|
||||
video = soup.find('video', src=True)
|
||||
video = soup.find("video", src=True)
|
||||
if video:
|
||||
video_url = video['src']
|
||||
video_url = video["src"]
|
||||
else:
|
||||
# Try to find in script tags
|
||||
scripts = soup.find_all('script')
|
||||
scripts = soup.find_all("script")
|
||||
for script in scripts:
|
||||
if script.string:
|
||||
# Look for iframe or video URLs in JavaScript
|
||||
@@ -274,20 +282,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
||||
if match:
|
||||
video_url = match.group(1)
|
||||
break
|
||||
if 'video_url' in locals():
|
||||
if "video_url" in locals():
|
||||
break
|
||||
|
||||
if 'video_url' not in locals():
|
||||
if "video_url" not in locals():
|
||||
raise ValueError("Could not find video player URL")
|
||||
|
||||
# Ensure absolute URL
|
||||
if video_url.startswith('//'):
|
||||
video_url = 'https:' + video_url
|
||||
elif video_url.startswith('/'):
|
||||
if video_url.startswith("//"):
|
||||
video_url = "https:" + video_url
|
||||
elif video_url.startswith("/"):
|
||||
video_url = self.base_url + video_url
|
||||
|
||||
# Extract episode title
|
||||
title_elem = soup.find('h1') or soup.find('h2')
|
||||
title_elem = soup.find("h1") or soup.find("h2")
|
||||
episode_title = title_elem.get_text(strip=True) if title_elem else "Episode"
|
||||
episode_title = sanitize_filename(episode_title)
|
||||
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
from .base import BaseAnimeSite
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
class NekoSamaDownloader(BaseAnimeSite):
|
||||
"""Downloader for neko-sama.org (anime streaming via Gupy)
|
||||
|
||||
NOTE: neko-sama.org now redirects to Gupy, which is a legal streaming search engine.
|
||||
It does NOT host video content - it provides metadata about where to watch legally.
|
||||
This provider can search and get metadata but cannot provide direct download links.
|
||||
"""
|
||||
|
||||
BASE_DOMAINS = ["neko-sama.org", "www.neko-sama.org", "neko-sama.fr", "nekosama.fr", "www.gupy.fr", "gupy.fr"]
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||
|
||||
async def get_download_link(self, url: str, target_filename: Optional[str] = None) -> tuple[str, str]:
|
||||
"""
|
||||
Extract download link from neko-sama URL.
|
||||
|
||||
NOTE: neko-sama.org/Gupy is a legal streaming search engine, NOT a video host.
|
||||
This returns streaming platform information instead of direct video links.
|
||||
"""
|
||||
try:
|
||||
# Check if this is a Gupy URL
|
||||
if 'gupy.fr' in url or 'neko-sama.org' in url:
|
||||
response = await self.client.get(url, follow_redirects=True)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
|
||||
# Look for streaming platform links
|
||||
streaming_links = []
|
||||
for link in soup.find_all('a', href=True):
|
||||
href = link.get('href', '')
|
||||
if '/out/' in href:
|
||||
text = link.get_text(strip=True)
|
||||
if text and 'Regarder' in text:
|
||||
streaming_links.append(f"{text}: {href}")
|
||||
|
||||
if streaming_links:
|
||||
title_elem = soup.find('h1') or soup.find('title')
|
||||
title = title_elem.get_text(strip=True).split('|')[0].strip() if title_elem else "Unknown"
|
||||
info = "Available streaming platforms:\n" + "\n".join(streaming_links[:5])
|
||||
filename = target_filename or f"{title}_streaming_info.txt"
|
||||
return info, filename
|
||||
|
||||
raise Exception("No streaming links found - Gupy is a legal streaming search, not a video host")
|
||||
|
||||
# Legacy: try original method for other URLs
|
||||
response = await self.client.get(url, follow_redirects=True)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
|
||||
# Method 1: Look for iframes with video
|
||||
iframes = soup.find_all('iframe')
|
||||
for iframe in iframes:
|
||||
src = iframe.get('src', '')
|
||||
if src and any(p in src for p in ['video', 'player', 'stream']):
|
||||
if not src.startswith('http'):
|
||||
src = urljoin(str(response.url), src)
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return src, filename
|
||||
|
||||
# Method 2: Look for video tags
|
||||
videos = soup.find_all('video')
|
||||
for video in videos:
|
||||
src = video.get('src') or video.get('data-src')
|
||||
if src:
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return src, filename
|
||||
|
||||
sources = video.find_all('source')
|
||||
for source in sources:
|
||||
src = source.get('src', '')
|
||||
if src:
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return src, filename
|
||||
|
||||
# Method 3: Look in scripts
|
||||
scripts = soup.find_all('script')
|
||||
for script in scripts:
|
||||
if script.string:
|
||||
patterns = [
|
||||
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)',
|
||||
r'"url":"([^"]+)"',
|
||||
r'"video":"([^"]+)"',
|
||||
]
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, script.string)
|
||||
for match in matches:
|
||||
match = match.replace('\\/', '/')
|
||||
if any(ext in match for ext in ['mp4', 'm3u8']):
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return match, filename
|
||||
|
||||
raise Exception("Could not find video link - Neko-Sama/Gupy does not host video content")
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error extracting NekoSama link: {str(e)}")
|
||||
|
||||
def _generate_filename(self, url: str) -> str:
|
||||
parts = url.split('/')
|
||||
anime_name = "anime"
|
||||
episode = "1"
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
if 'episode' in part.lower():
|
||||
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
|
||||
if match:
|
||||
episode = match.group(1)
|
||||
|
||||
filename = f"{anime_name} - Episode {episode}.mp4"
|
||||
return filename.title()
|
||||
|
||||
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
|
||||
"""Get list of episodes for an anime."""
|
||||
try:
|
||||
response = await self.client.get(anime_url)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
|
||||
episodes = []
|
||||
# Try to find episode links
|
||||
episode_links = soup.find_all('a', href=re.compile(r'episode'))
|
||||
|
||||
for link in episode_links:
|
||||
href = link.get('href', '')
|
||||
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
|
||||
if match:
|
||||
episode_num = match.group(1)
|
||||
if not href.startswith('http'):
|
||||
href = urljoin(anime_url, href)
|
||||
|
||||
episodes.append({'episode': episode_num, 'url': href})
|
||||
|
||||
# Deduplicate and sort
|
||||
seen = set()
|
||||
unique_episodes = []
|
||||
for ep in episodes:
|
||||
if ep['episode'] not in seen:
|
||||
seen.add(ep['episode'])
|
||||
unique_episodes.append(ep)
|
||||
|
||||
unique_episodes.sort(key=lambda x: int(x['episode']))
|
||||
return unique_episodes
|
||||
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
async def get_anime_metadata(self, anime_url: str) -> dict:
|
||||
"""Extract rich metadata from anime page."""
|
||||
try:
|
||||
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
|
||||
response = await self.client.get(anime_url)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
|
||||
metadata = {
|
||||
'synopsis': None,
|
||||
'genres': [],
|
||||
'rating': None,
|
||||
'release_year': None,
|
||||
'studio': None,
|
||||
'poster_image': None,
|
||||
'banner_image': None,
|
||||
'total_episodes': None,
|
||||
'status': None,
|
||||
'alternative_titles': []
|
||||
}
|
||||
|
||||
# Extract title and year from h1
|
||||
title_elem = soup.find('h1')
|
||||
if title_elem:
|
||||
title_text = title_elem.get_text(strip=True)
|
||||
# Extract year from title like "Naruto (2002)"
|
||||
year_match = re.search(r'\((\d{4})\)', title_text)
|
||||
if year_match:
|
||||
metadata['release_year'] = int(year_match.group(1))
|
||||
|
||||
# Extract synopsis - Gupy shows it as paragraphs
|
||||
synopsis_elem = soup.find('p')
|
||||
if synopsis_elem:
|
||||
text = synopsis_elem.get_text(strip=True)
|
||||
if len(text) > 50:
|
||||
metadata['synopsis'] = text
|
||||
|
||||
# Extract genres from meta tags or links
|
||||
genre_links = soup.find_all('a', href=re.compile(r'serie-|genre|tag'))
|
||||
if genre_links:
|
||||
genres = []
|
||||
for link in genre_links[:5]:
|
||||
text = link.get_text(strip=True)
|
||||
if text and '/' not in text and len(text) < 30:
|
||||
genres.append(text)
|
||||
metadata['genres'] = genres
|
||||
|
||||
# Extract rating from percentage
|
||||
rating_elem = soup.find(string=re.compile(r'\d+(\.\d+)?%'))
|
||||
if rating_elem:
|
||||
match = re.search(r'(\d+(\.\d+)?)%', rating_elem)
|
||||
if match:
|
||||
rating = float(match.group(1)) / 10
|
||||
metadata['rating'] = f"{rating:.1f}/10"
|
||||
|
||||
# Extract poster image
|
||||
poster_elem = soup.find('img', src=re.compile(r'poster|poster'))
|
||||
if poster_elem:
|
||||
metadata['poster_image'] = poster_elem.get('src')
|
||||
|
||||
# Extract episode count from page text
|
||||
page_text = soup.get_text()
|
||||
ep_match = re.search(r'(\d+)\s*episodes?', page_text, re.I)
|
||||
if ep_match:
|
||||
metadata['total_episodes'] = int(ep_match.group(1))
|
||||
|
||||
# Extract studio/director
|
||||
director_elem = soup.find('a', href=re.compile(r'person|réalisé'))
|
||||
if director_elem:
|
||||
metadata['studio'] = director_elem.get_text(strip=True)
|
||||
|
||||
print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
|
||||
return metadata
|
||||
|
||||
except Exception as e:
|
||||
print(f"[NEKO-SAMA] Error extracting metadata: {e}")
|
||||
return {}
|
||||
|
||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
||||
"""Search for anime on neko-sama (uses Gupy backend)."""
|
||||
try:
|
||||
import time
|
||||
from html import unescape
|
||||
start = time.time()
|
||||
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
|
||||
|
||||
# Neko-Sama now uses Gupy - try the direct URL pattern
|
||||
search_slug = query.lower().replace(' ', '-')
|
||||
search_urls = [
|
||||
f"https://www.gupy.fr/series/{search_slug}/",
|
||||
f"https://neko-sama.org/series/{search_slug}/",
|
||||
]
|
||||
|
||||
results = []
|
||||
for search_url in search_urls:
|
||||
response = await self.client.get(search_url, follow_redirects=True)
|
||||
print(f"[NEKO-SAMA] Tried {search_url} -> {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
final_url = str(response.url)
|
||||
print(f"[NEKO-SAMA] Found anime at {final_url}")
|
||||
|
||||
# Extract title from page
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
title_elem = soup.find('h1') or soup.find('title')
|
||||
title = unescape(title_elem.get_text(strip=True)) if title_elem else query
|
||||
# Clean up title
|
||||
title = title.split('|')[0].split('-')[0].strip()
|
||||
|
||||
result = {
|
||||
'title': title,
|
||||
'url': final_url,
|
||||
'cover_image': None,
|
||||
'type': 'direct',
|
||||
'metadata': None
|
||||
}
|
||||
|
||||
# Try to get poster
|
||||
poster = soup.find('img', src=re.compile(r'poster'))
|
||||
if poster:
|
||||
result['cover_image'] = poster.get('src')
|
||||
|
||||
if include_metadata:
|
||||
metadata = await self.get_anime_metadata(final_url)
|
||||
result['metadata'] = metadata
|
||||
|
||||
results.append(result)
|
||||
break
|
||||
|
||||
elapsed = time.time() - start
|
||||
print(f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
print(f"[NEKO-SAMA] Error: {str(e)}")
|
||||
return []
|
||||
@@ -9,6 +9,10 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
|
||||
BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.id = "vostfree"
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||
|
||||
@@ -16,35 +20,35 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
"""Extract download link from vostfree URL"""
|
||||
try:
|
||||
response = await self.client.get(url, follow_redirects=True)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
# Method 1: Look for iframe players
|
||||
iframes = soup.find_all('iframe')
|
||||
iframes = soup.find_all("iframe")
|
||||
for iframe in iframes:
|
||||
src = iframe.get('src', '')
|
||||
if src and any(p in src for p in ['player', 'video', 'stream']):
|
||||
if not src.startswith('http'):
|
||||
src = iframe.get("src", "")
|
||||
if src and any(p in src for p in ["player", "video", "stream"]):
|
||||
if not src.startswith("http"):
|
||||
src = urljoin(str(response.url), src)
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return src, filename
|
||||
|
||||
# Method 2: Look for video tags
|
||||
videos = soup.find_all('video')
|
||||
videos = soup.find_all("video")
|
||||
for video in videos:
|
||||
src = video.get('src')
|
||||
src = video.get("src")
|
||||
if src:
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return src, filename
|
||||
|
||||
sources = video.find_all('source')
|
||||
sources = video.find_all("source")
|
||||
for source in sources:
|
||||
src = source.get('src', '')
|
||||
if src and any(ext in src for ext in ['mp4', 'm3u8']):
|
||||
src = source.get("src", "")
|
||||
if src and any(ext in src for ext in ["mp4", "m3u8"]):
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return src, filename
|
||||
|
||||
# Method 3: Look in scripts
|
||||
scripts = soup.find_all('script')
|
||||
scripts = soup.find_all("script")
|
||||
for script in scripts:
|
||||
if script.string:
|
||||
patterns = [
|
||||
@@ -56,8 +60,8 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, script.string)
|
||||
for match in matches:
|
||||
match = match.replace('\\/', '/')
|
||||
if any(ext in match for ext in ['mp4', 'm3u8']):
|
||||
match = match.replace("\\/", "/")
|
||||
if any(ext in match for ext in ["mp4", "m3u8"]):
|
||||
filename = self._generate_filename(str(response.url))
|
||||
return match, filename
|
||||
|
||||
@@ -67,12 +71,12 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
raise Exception(f"Error extracting Vostfree link: {str(e)}")
|
||||
|
||||
def _generate_filename(self, url: str) -> str:
|
||||
parts = url.split('/')
|
||||
parts = url.split("/")
|
||||
anime_name = "anime"
|
||||
episode = "1"
|
||||
|
||||
for part in parts:
|
||||
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
|
||||
match = re.search(r"episode[-\s]*(\d+)", part, re.I)
|
||||
if match:
|
||||
episode = match.group(1)
|
||||
|
||||
@@ -82,30 +86,30 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
|
||||
try:
|
||||
response = await self.client.get(anime_url)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
episodes = []
|
||||
episode_links = soup.find_all('a', href=re.compile(r'episode', re.I))
|
||||
episode_links = soup.find_all("a", href=re.compile(r"episode", re.I))
|
||||
|
||||
for link in episode_links:
|
||||
href = link.get('href', '')
|
||||
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
|
||||
href = link.get("href", "")
|
||||
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
|
||||
if match:
|
||||
episode_num = match.group(1)
|
||||
if not href.startswith('http'):
|
||||
if not href.startswith("http"):
|
||||
href = urljoin(anime_url, href)
|
||||
|
||||
episodes.append({'episode': episode_num, 'url': href})
|
||||
episodes.append({"episode": episode_num, "url": href})
|
||||
|
||||
# Deduplicate and sort
|
||||
seen = set()
|
||||
unique_episodes = []
|
||||
for ep in episodes:
|
||||
if ep['episode'] not in seen:
|
||||
seen.add(ep['episode'])
|
||||
if ep["episode"] not in seen:
|
||||
seen.add(ep["episode"])
|
||||
unique_episodes.append(ep)
|
||||
|
||||
unique_episodes.sort(key=lambda x: int(x['episode']))
|
||||
unique_episodes.sort(key=lambda x: int(x["episode"]))
|
||||
return unique_episodes
|
||||
|
||||
except Exception as e:
|
||||
@@ -119,29 +123,29 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
try:
|
||||
print(f"[VOSTFREE] Extracting metadata from: {anime_url}")
|
||||
response = await self.client.get(anime_url)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
metadata = {
|
||||
'synopsis': None,
|
||||
'genres': [],
|
||||
'rating': None,
|
||||
'release_year': None,
|
||||
'studio': None,
|
||||
'poster_image': None,
|
||||
'banner_image': None,
|
||||
'total_episodes': None,
|
||||
'status': None,
|
||||
'alternative_titles': []
|
||||
"synopsis": None,
|
||||
"genres": [],
|
||||
"rating": None,
|
||||
"release_year": None,
|
||||
"studio": None,
|
||||
"poster_image": None,
|
||||
"banner_image": None,
|
||||
"total_episodes": None,
|
||||
"status": None,
|
||||
"alternative_titles": [],
|
||||
}
|
||||
|
||||
# Extract synopsis
|
||||
synopsis_selectors = [
|
||||
'div.synopsis',
|
||||
'div.description',
|
||||
"div.synopsis",
|
||||
"div.description",
|
||||
'div[class*="synopsis"]',
|
||||
'div[class*="desc"]',
|
||||
'p.synopsis',
|
||||
'.anime-synopsis'
|
||||
"p.synopsis",
|
||||
".anime-synopsis",
|
||||
]
|
||||
|
||||
for selector in synopsis_selectors:
|
||||
@@ -149,57 +153,65 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
if synopsis_elem:
|
||||
synopsis = synopsis_elem.get_text(strip=True)
|
||||
if len(synopsis) > 50:
|
||||
metadata['synopsis'] = synopsis
|
||||
metadata["synopsis"] = synopsis
|
||||
break
|
||||
|
||||
# Extract genres
|
||||
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I))
|
||||
genre_links = soup.find_all("a", href=re.compile(r"genre|tag|type", re.I))
|
||||
if genre_links:
|
||||
metadata['genres'] = [link.get_text(strip=True) for link in genre_links[:5]]
|
||||
metadata["genres"] = [
|
||||
link.get_text(strip=True) for link in genre_links[:5]
|
||||
]
|
||||
|
||||
# Extract rating
|
||||
rating_selectors = [
|
||||
'span.rating',
|
||||
'div.rating',
|
||||
'span.score',
|
||||
"span.rating",
|
||||
"div.rating",
|
||||
"span.score",
|
||||
'div[class*="rating"]',
|
||||
'div[class*="score"]'
|
||||
'div[class*="score"]',
|
||||
]
|
||||
|
||||
for selector in rating_selectors:
|
||||
rating_elem = soup.select_one(selector)
|
||||
if rating_elem:
|
||||
rating_text = rating_elem.get_text(strip=True)
|
||||
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
|
||||
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
|
||||
if rating_match:
|
||||
metadata['rating'] = f"{rating_match.group(1)}/10"
|
||||
metadata["rating"] = f"{rating_match.group(1)}/10"
|
||||
break
|
||||
|
||||
# Extract release year
|
||||
page_text = soup.get_text()
|
||||
year_matches = re.findall(r'\b(19\d{2}|20\d{2})\b', page_text)
|
||||
year_matches = re.findall(r"\b(19\d{2}|20\d{2})\b", page_text)
|
||||
if year_matches:
|
||||
import datetime
|
||||
|
||||
current_year = datetime.datetime.now().year + 2
|
||||
valid_years = [int(y) for y in year_matches if 1950 <= int(y) <= current_year]
|
||||
valid_years = [
|
||||
int(y) for y in year_matches if 1950 <= int(y) <= current_year
|
||||
]
|
||||
if valid_years:
|
||||
from collections import Counter
|
||||
metadata['release_year'] = Counter(valid_years).most_common(1)[0][0]
|
||||
|
||||
metadata["release_year"] = Counter(valid_years).most_common(1)[0][0]
|
||||
|
||||
# Extract poster image
|
||||
poster_elem = soup.select_one('img.poster, img.cover, .anime-poster img')
|
||||
poster_elem = soup.select_one("img.poster, img.cover, .anime-poster img")
|
||||
if poster_elem:
|
||||
metadata['poster_image'] = poster_elem.get('src') or poster_elem.get('data-src')
|
||||
metadata["poster_image"] = poster_elem.get("src") or poster_elem.get(
|
||||
"data-src"
|
||||
)
|
||||
|
||||
# Extract poster from og:image
|
||||
og_image = soup.find('meta', property='og:image')
|
||||
if og_image and not metadata['poster_image']:
|
||||
metadata['poster_image'] = og_image.get('content')
|
||||
og_image = soup.find("meta", property="og:image")
|
||||
if og_image and not metadata["poster_image"]:
|
||||
metadata["poster_image"] = og_image.get("content")
|
||||
|
||||
# Extract total episodes
|
||||
episodes_count = len(await self.get_episodes(anime_url))
|
||||
if episodes_count > 0:
|
||||
metadata['total_episodes'] = episodes_count
|
||||
metadata["total_episodes"] = episodes_count
|
||||
|
||||
print(f"[VOSTFREE] Extracted metadata: {metadata}")
|
||||
return metadata
|
||||
@@ -208,7 +220,9 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
print(f"[VOSTFREE] Error extracting metadata: {e}")
|
||||
return {}
|
||||
|
||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
||||
async def search_anime(
|
||||
self, query: str, lang: str = "vostfr", include_metadata: bool = False
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Search for anime on vostfree
|
||||
|
||||
@@ -219,6 +233,7 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
"""
|
||||
try:
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
print(f"[VOSTFREE] Searching for '{query}' ({lang})...")
|
||||
|
||||
@@ -233,15 +248,15 @@ class VostfreeDownloader(BaseAnimeSite):
|
||||
if response.status_code == 200:
|
||||
print(f"[VOSTFREE] Found anime at {str(response.url)}")
|
||||
result = {
|
||||
'title': query,
|
||||
'url': str(response.url),
|
||||
'type': 'direct',
|
||||
'metadata': None
|
||||
"title": query,
|
||||
"url": str(response.url),
|
||||
"type": "direct",
|
||||
"metadata": None,
|
||||
}
|
||||
|
||||
if include_metadata:
|
||||
metadata = await self.get_anime_metadata(str(response.url))
|
||||
result['metadata'] = metadata
|
||||
result["metadata"] = metadata
|
||||
|
||||
return [result]
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Generic scraper driven by YAML configuration"""
|
||||
import yaml
|
||||
import logging
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import List, Dict, Optional, Any
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, quote
|
||||
|
||||
from app.downloaders.anime_sites.base import BaseAnimeSite
|
||||
from app.models import AnimeSearchResult, AnimeMetadata
|
||||
from app.metadata_enrichment import get_metadata_enricher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenericScraper(BaseAnimeSite):
|
||||
"""A scraper that uses external configuration for its logic"""
|
||||
|
||||
def __init__(self, config_path: str):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
self.config = yaml.safe_load(f)
|
||||
|
||||
self.id = self.config['id']
|
||||
self.name = self.config['name']
|
||||
self.base_url = self.config['base_url']
|
||||
self.mirrors = self.config.get('mirrors', [])
|
||||
|
||||
# Current active base URL (can change if mirror found)
|
||||
self.active_url = self.base_url
|
||||
|
||||
self.client = httpx.AsyncClient(
|
||||
timeout=20.0,
|
||||
follow_redirects=True,
|
||||
headers={
|
||||
"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"
|
||||
}
|
||||
)
|
||||
|
||||
async def search(self, query: str) -> List[AnimeSearchResult]:
|
||||
"""Search using configured selectors"""
|
||||
search_config = self.config.get('search')
|
||||
if not search_config:
|
||||
logger.warning(f"No search config for {self.name}")
|
||||
return []
|
||||
|
||||
search_path = search_config['path'].format(query=quote(query))
|
||||
url = urljoin(self.active_url, search_path)
|
||||
|
||||
try:
|
||||
response = await self.client.get(url)
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
|
||||
results = []
|
||||
container = search_config.get('container_selector')
|
||||
items = soup.select(container) if container else [soup]
|
||||
|
||||
for item in items:
|
||||
try:
|
||||
title_node = item.select_one(search_config['title_selector'])
|
||||
url_node = item.select_one(search_config['url_selector'])
|
||||
|
||||
if not title_node or not url_node:
|
||||
continue
|
||||
|
||||
title = title_node.get_text(strip=True)
|
||||
href = url_node.get('href')
|
||||
anime_url = urljoin(self.active_url, href)
|
||||
|
||||
img_node = item.select_one(search_config.get('image_selector', 'img'))
|
||||
cover_image = img_node.get('src') if img_node else None
|
||||
if cover_image:
|
||||
cover_image = urljoin(self.active_url, cover_image)
|
||||
|
||||
# Initial metadata from scraper
|
||||
meta_dict = {
|
||||
"poster_image": cover_image,
|
||||
"status": "Unknown"
|
||||
}
|
||||
|
||||
# Enrich with Kitsu via global service
|
||||
enricher = await get_metadata_enricher()
|
||||
metadata = await enricher.enrich_metadata(meta_dict, title, anime_url)
|
||||
|
||||
results.append(AnimeSearchResult(
|
||||
title=title,
|
||||
url=anime_url,
|
||||
cover_image=metadata.poster_image or cover_image,
|
||||
type="search_result",
|
||||
metadata=metadata
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing search result item: {e}")
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Search failed for {self.name}: {e}")
|
||||
return []
|
||||
|
||||
async def get_episodes(self, anime_url: str) -> List[Dict[str, Any]]:
|
||||
"""Get episodes list (to be specialized if site logic is complex)"""
|
||||
# Default implementation for simple sites
|
||||
# For complex sites like Anime-Sama, we might still need a specialized subclass
|
||||
# but driven by the YAML config for base parameters.
|
||||
return []
|
||||
|
||||
async def check_health(self) -> bool:
|
||||
"""Check if the site is up and selectors still work"""
|
||||
try:
|
||||
# Try a test search for a very common anime
|
||||
results = await self.search("One Piece")
|
||||
is_healthy = len(results) > 0
|
||||
if not is_healthy:
|
||||
logger.warning(f"Health check failed for {self.name}: No results found")
|
||||
return is_healthy
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed for {self.name} with error: {e}")
|
||||
return False
|
||||
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
@@ -0,0 +1,24 @@
|
||||
name: "Anime-Sama"
|
||||
id: "animesama"
|
||||
base_url: "https://anime-sama.fr"
|
||||
mirrors:
|
||||
- "https://anime-sama.si"
|
||||
- "https://anime-sama.co"
|
||||
|
||||
search:
|
||||
path: "/search?q={query}"
|
||||
container_selector: ".result-item"
|
||||
title_selector: "h3"
|
||||
url_selector: "a"
|
||||
image_selector: "img"
|
||||
|
||||
episodes:
|
||||
container_selector: "#episodes-list"
|
||||
item_selector: ".episode-item"
|
||||
# Logic for Anime-Sama can be complex, we'll handle custom logic in GenericScraper
|
||||
# but keep common selectors here.
|
||||
player_iframe_selector: "iframe#player"
|
||||
|
||||
metadata:
|
||||
synopsis_selector: ".synopsis"
|
||||
genres_selector: ".genres .genre"
|
||||
@@ -2,10 +2,12 @@
|
||||
from .base import BaseSeriesSite
|
||||
# Import all series site downloaders
|
||||
from .fs7 import FS7Downloader
|
||||
from .zonetelechargement import ZoneTelechargementDownloader
|
||||
|
||||
__all__ = [
|
||||
"BaseSeriesSite",
|
||||
"FS7Downloader",
|
||||
"ZoneTelechargementDownloader",
|
||||
]
|
||||
|
||||
|
||||
@@ -13,6 +15,7 @@ def get_series_site(url: str) -> BaseSeriesSite:
|
||||
"""Factory function to get the appropriate series site for a URL"""
|
||||
sites = [
|
||||
FS7Downloader(),
|
||||
ZoneTelechargementDownloader(),
|
||||
]
|
||||
|
||||
for site in sites:
|
||||
|
||||
+131
-114
@@ -1,4 +1,5 @@
|
||||
"""FS7 (French Stream) series site downloader"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
@@ -19,29 +20,46 @@ class FS7Downloader(BaseSeriesSite):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.base_url = "https://fs7.lol"
|
||||
self.search_url = f"{self.base_url}/"
|
||||
# Update client headers to mimic browser
|
||||
self.client.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1'
|
||||
})
|
||||
self.id = "fs7"
|
||||
self.provider_id = "fs7"
|
||||
self.default_domain = "fs7.lol"
|
||||
self.test_tlds = ["lol", "com", "net", "org", "tv", "ws", "cc", "co"]
|
||||
self.base_url = f"https://{self.default_domain}"
|
||||
self._domain_checked = False
|
||||
self.client.headers.update(
|
||||
{
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
}
|
||||
)
|
||||
|
||||
async def _ensure_base_url(self):
|
||||
"""Ensure base_url is set to the current active domain"""
|
||||
if self._domain_checked:
|
||||
return
|
||||
self._domain_checked = True
|
||||
try:
|
||||
from app.utils import DomainManager
|
||||
|
||||
active_domain = await DomainManager.get_active_domain(
|
||||
self.provider_id, self.default_domain, self.test_tlds, test_path="/"
|
||||
)
|
||||
self.base_url = f"https://{active_domain}"
|
||||
logger.info(f"Using active domain for FS7: {self.base_url}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Domain check failed for FS7, using default: {e}")
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
"""Check if this downloader can handle the given URL"""
|
||||
return "fs7.lol" in url.lower() or "french-stream" in url.lower()
|
||||
|
||||
async def search_anime(
|
||||
self,
|
||||
query: str,
|
||||
lang: str = "vf"
|
||||
) -> List[Dict[str, str]]:
|
||||
async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
|
||||
"""
|
||||
Search for series on FS7.
|
||||
Search for series on FS7 using DLE AJAX search endpoint.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
@@ -51,63 +69,61 @@ class FS7Downloader(BaseSeriesSite):
|
||||
List of series with title, url, cover_image
|
||||
"""
|
||||
try:
|
||||
await self._ensure_base_url()
|
||||
logger.info(f"Searching FS7 for: {query}")
|
||||
|
||||
# FS7 uses GET request with query parameters for search
|
||||
response = await self.client.get(
|
||||
self.search_url,
|
||||
params={
|
||||
"do": "search",
|
||||
"subaction": "search",
|
||||
"story": query
|
||||
}
|
||||
ajax_url = f"{self.base_url}/engine/ajax/search.php"
|
||||
response = await self.client.post(
|
||||
ajax_url,
|
||||
data={"query": query, "page": "1"},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Referer": f"{self.base_url}/",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
results = []
|
||||
|
||||
# Look for series items (FS7 has both films and series in search results)
|
||||
# We filter for /s-tv/ URLs ending with .html (actual series/season pages)
|
||||
items = soup.find_all('a', href=re.compile(r'/s-tv/\d+-.+\.html'))
|
||||
|
||||
for item in items[:20]: # Limit to 20 results
|
||||
url = item.get('href', '')
|
||||
if not url.startswith('http'):
|
||||
for item in soup.find_all("div", class_="search-item")[:24]:
|
||||
onclick = item.get("onclick", "")
|
||||
url_match = re.search(r"location\.href=['\"]([^'\"]+)['\"]", onclick)
|
||||
if not url_match:
|
||||
continue
|
||||
url = url_match.group(1)
|
||||
if not url.startswith("http"):
|
||||
url = urljoin(self.base_url, url)
|
||||
|
||||
# Extract title from the item
|
||||
title_elem = item.find('img', alt=True)
|
||||
if title_elem:
|
||||
title = title_elem.get('alt', '').strip()
|
||||
else:
|
||||
# Get text content and clean it
|
||||
text = item.get_text(strip=True)
|
||||
# Skip if it's just a category name
|
||||
if any(cat in text.lower() for cat in ['séries', 'series', 'vf', 'vostfr', 'vo', 'netflix', 'disney', 'amazon', 'apple']):
|
||||
continue
|
||||
title = text
|
||||
title_elem = item.find("div", class_="search-title")
|
||||
title = title_elem.get_text(strip=True) if title_elem else ""
|
||||
title = re.sub(r"\s+", " ", title).strip()
|
||||
|
||||
# Clean up title: remove "affiche" suffix and clean extra whitespace
|
||||
title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip()
|
||||
title = re.sub(r'\s+', ' ', title) # Normalize whitespace
|
||||
cover_image = ""
|
||||
poster_elem = item.find("div", class_="search-poster")
|
||||
if poster_elem:
|
||||
img = poster_elem.find("img")
|
||||
if img:
|
||||
cover_image = (
|
||||
img.get("data-src")
|
||||
or img.get("data-original")
|
||||
or img.get("src")
|
||||
or ""
|
||||
)
|
||||
|
||||
# Extract cover image
|
||||
img = item.find('img')
|
||||
cover_image = img.get('src', '') if img else ''
|
||||
if title and len(title) > 2:
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"url": url,
|
||||
"cover_image": cover_image,
|
||||
"provider_id": self.provider_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Only add if we have a title and it's not empty
|
||||
if title and len(title) > 5:
|
||||
# Avoid duplicates
|
||||
if not any(r['url'] == url for r in results):
|
||||
results.append({
|
||||
'title': title,
|
||||
'url': url,
|
||||
'cover_image': cover_image
|
||||
})
|
||||
|
||||
logger.info(f"Found {len(results)} series on FS7")
|
||||
logger.info(f"Found {len(results)} results on FS7 for '{query}'")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
@@ -115,9 +131,7 @@ class FS7Downloader(BaseSeriesSite):
|
||||
return []
|
||||
|
||||
async def get_episodes(
|
||||
self,
|
||||
anime_url: str,
|
||||
lang: str = "vf"
|
||||
self, anime_url: str, lang: str = "vf"
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get episode list for a series.
|
||||
@@ -136,31 +150,33 @@ class FS7Downloader(BaseSeriesSite):
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
episodes = []
|
||||
|
||||
# Get series title for episode naming
|
||||
title_elem = soup.find('h1')
|
||||
title_elem = soup.find("h1")
|
||||
series_title = title_elem.get_text(strip=True) if title_elem else "Series"
|
||||
# Clean up title: remove "affiche" suffix
|
||||
series_title = re.sub(r'\s+affiche$', '', series_title, flags=re.IGNORECASE).strip()
|
||||
series_title = re.sub(
|
||||
r"\s+affiche$", "", series_title, flags=re.IGNORECASE
|
||||
).strip()
|
||||
|
||||
# FS7 stores episode data in JavaScript div elements
|
||||
# Format: <div data-ep="1" data-vidzy="..." data-uqload="..." data-netu="..." data-voe="..."></div>
|
||||
episode_divs = soup.find_all('div', attrs={'data-ep': True})
|
||||
episode_divs = soup.find_all("div", attrs={"data-ep": True})
|
||||
|
||||
for div in episode_divs:
|
||||
ep_num = div.get('data-ep', '').strip()
|
||||
ep_num = div.get("data-ep", "").strip()
|
||||
|
||||
# Try different video players in order of preference
|
||||
video_url = None
|
||||
host_name = None
|
||||
for player in ['data-vidzy', 'data-uqload', 'data-voe', 'data-netu']:
|
||||
player_url = div.get(player, '').strip()
|
||||
for player in ["data-vidzy", "data-uqload", "data-voe", "data-netu"]:
|
||||
player_url = div.get(player, "").strip()
|
||||
if player_url:
|
||||
video_url = player_url
|
||||
# Extract host name from attribute name
|
||||
host_name = player.replace('data-', '').title()
|
||||
host_name = player.replace("data-", "").title()
|
||||
logger.debug(f"Found episode {ep_num} on {host_name}")
|
||||
break
|
||||
|
||||
@@ -171,15 +187,19 @@ class FS7Downloader(BaseSeriesSite):
|
||||
# Use pipe-separated format: video_url|anime_url|episode_title
|
||||
combined_url = f"{video_url}|{anime_url}|{episode_title}"
|
||||
|
||||
episodes.append({
|
||||
'episode': ep_num,
|
||||
'url': combined_url,
|
||||
'title': episode_title,
|
||||
'host': host_name or 'Unknown'
|
||||
})
|
||||
episodes.append(
|
||||
{
|
||||
"episode": ep_num,
|
||||
"url": combined_url,
|
||||
"title": episode_title,
|
||||
"host": host_name or "Unknown",
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by episode number
|
||||
episodes.sort(key=lambda x: int(x['episode']) if x['episode'].isdigit() else 0)
|
||||
episodes.sort(
|
||||
key=lambda x: int(x["episode"]) if x["episode"].isdigit() else 0
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(episodes)} episodes")
|
||||
return episodes
|
||||
@@ -188,10 +208,7 @@ class FS7Downloader(BaseSeriesSite):
|
||||
logger.error(f"Error getting episodes from FS7: {e}")
|
||||
return []
|
||||
|
||||
async def get_anime_metadata(
|
||||
self,
|
||||
anime_url: str
|
||||
) -> Dict[str, Any]:
|
||||
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get metadata for a series.
|
||||
|
||||
@@ -208,62 +225,62 @@ class FS7Downloader(BaseSeriesSite):
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
# Extract title
|
||||
title = soup.find('h1')
|
||||
title = soup.find("h1")
|
||||
title = title.get_text(strip=True) if title else "Unknown"
|
||||
|
||||
# Clean up title: remove "affiche" suffix
|
||||
title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip()
|
||||
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
|
||||
|
||||
# Extract description/synopsis
|
||||
description_elem = soup.find('div', class_='full-text')
|
||||
description = description_elem.get_text(strip=True) if description_elem else ""
|
||||
description_elem = soup.find("div", class_="full-text")
|
||||
description = (
|
||||
description_elem.get_text(strip=True) if description_elem else ""
|
||||
)
|
||||
|
||||
# Extract cover image
|
||||
img = soup.find('img', class_='poster')
|
||||
poster_image = img.get('src', '') if img else ''
|
||||
img = soup.find("img", class_="poster")
|
||||
poster_image = img.get("src", "") if img else ""
|
||||
|
||||
# Try to get poster from meta tag if not found
|
||||
if not poster_image:
|
||||
meta_img = soup.find('meta', property='og:image')
|
||||
poster_image = meta_img.get('content', '') if meta_img else ''
|
||||
meta_img = soup.find("meta", property="og:image")
|
||||
poster_image = meta_img.get("content", "") if meta_img else ""
|
||||
|
||||
# Extract year
|
||||
year_match = re.search(r'\b(19|20)\d{2}\b', description)
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", description)
|
||||
release_year = int(year_match.group()) if year_match else None
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'synopsis': description,
|
||||
'poster_image': poster_image,
|
||||
'release_year': release_year,
|
||||
'genres': [],
|
||||
'rating': None,
|
||||
'studio': None,
|
||||
'total_episodes': None,
|
||||
'status': None
|
||||
"title": title,
|
||||
"synopsis": description,
|
||||
"poster_image": poster_image,
|
||||
"release_year": release_year,
|
||||
"genres": [],
|
||||
"rating": None,
|
||||
"studio": None,
|
||||
"total_episodes": None,
|
||||
"status": None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting metadata from FS7: {e}")
|
||||
return {
|
||||
'title': "Unknown",
|
||||
'synopsis': "",
|
||||
'poster_image': '',
|
||||
'genres': [],
|
||||
'rating': None,
|
||||
'release_year': None,
|
||||
'studio': None,
|
||||
'total_episodes': None,
|
||||
'status': None
|
||||
"title": "Unknown",
|
||||
"synopsis": "",
|
||||
"poster_image": "",
|
||||
"genres": [],
|
||||
"rating": None,
|
||||
"release_year": None,
|
||||
"studio": None,
|
||||
"total_episodes": None,
|
||||
"status": None,
|
||||
}
|
||||
|
||||
async def get_download_link(
|
||||
self,
|
||||
url: str,
|
||||
target_filename: Optional[str] = None
|
||||
self, url: str, target_filename: Optional[str] = None
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Extract download link from video player URL.
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Zone-Telechargement series site downloader"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from urllib.parse import urljoin, quote
|
||||
from bs4 import BeautifulSoup
|
||||
from app.utils import DomainManager
|
||||
from .base import BaseSeriesSite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZoneTelechargementDownloader(BaseSeriesSite):
|
||||
"""
|
||||
Downloader for Zone-Telechargement series site.
|
||||
Handles dynamic TLD verification.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.id = "zonetelechargement"
|
||||
self.provider_id = "zonetelechargement"
|
||||
self.default_domain = "zone-telechargement.golf"
|
||||
self.test_tlds = ["golf", "cam", "net", "org", "blue", "lol", "work", "ws"]
|
||||
self.base_url = f"https://{self.default_domain}"
|
||||
self._domain_checked = False
|
||||
|
||||
self.client.headers.update(
|
||||
{
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
)
|
||||
|
||||
async def _ensure_base_url(self):
|
||||
"""Ensure base_url is set to the current active domain"""
|
||||
if self._domain_checked:
|
||||
return
|
||||
self._domain_checked = True
|
||||
try:
|
||||
active_domain = await DomainManager.get_active_domain(
|
||||
self.provider_id, self.default_domain, self.test_tlds, test_path="/"
|
||||
)
|
||||
self.base_url = f"https://{active_domain}"
|
||||
logger.info(f"Using active domain for Zone-Telechargement: {self.base_url}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Domain check failed for Zone-Telechargement, using default: {e}"
|
||||
)
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
"""Check if this downloader can handle the given URL"""
|
||||
return "zone-telechargement" in url.lower() or "zt-za" in url.lower()
|
||||
|
||||
async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
|
||||
"""Search for series on Zone-Telechargement.
|
||||
|
||||
ZT uses server-side rendered search: GET /?p=series&search=QUERY.
|
||||
Results are in div.cover_global containers with nested cover_infos_title links.
|
||||
"""
|
||||
try:
|
||||
await self._ensure_base_url()
|
||||
logger.info(f"Searching Zone-Telechargement for: {query}")
|
||||
|
||||
search_url = f"{self.base_url}/"
|
||||
params = {"p": "series", "search": query}
|
||||
|
||||
response = await self.client.get(search_url, params=params)
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
results = []
|
||||
|
||||
for cover_div in soup.find_all("div", class_="cover_global")[:24]:
|
||||
link_in_cover = cover_div.find("a", class_="mainimg")
|
||||
if not link_in_cover:
|
||||
link_in_cover = cover_div.find("a")
|
||||
|
||||
if not link_in_cover:
|
||||
continue
|
||||
|
||||
url = link_in_cover.get("href", "")
|
||||
if not url.startswith("http"):
|
||||
url = urljoin(self.base_url, url)
|
||||
|
||||
img = cover_div.find("img")
|
||||
cover_image = ""
|
||||
if img:
|
||||
cover_image = img.get("data-src") or img.get("src") or ""
|
||||
if cover_image and not cover_image.startswith("http"):
|
||||
cover_image = urljoin(self.base_url, cover_image)
|
||||
|
||||
title = ""
|
||||
info_div = cover_div.find("div", class_="cover_infos_title")
|
||||
if info_div:
|
||||
title_link = info_div.find("a")
|
||||
if title_link:
|
||||
title = title_link.get_text(strip=True)
|
||||
else:
|
||||
title = info_div.get_text(strip=True)
|
||||
else:
|
||||
title = link_in_cover.get("title", "")
|
||||
if not title:
|
||||
title = link_in_cover.get_text(strip=True)
|
||||
|
||||
if title and len(title) > 2:
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"url": url,
|
||||
"cover_image": cover_image,
|
||||
"provider_id": self.provider_id,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Zone-Telechargement found {len(results)} results for '{query}'"
|
||||
)
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching Zone-Telechargement: {e}")
|
||||
return []
|
||||
|
||||
async def get_episodes(
|
||||
self, anime_url: str, lang: str = "vf"
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Extract episodes from a series page"""
|
||||
try:
|
||||
await self._ensure_base_url()
|
||||
html = await self._fetch_page(anime_url)
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
episodes = []
|
||||
seen_urls = set()
|
||||
|
||||
# ZT lists episodes as <a> tags inside <b> inside div.postinfo
|
||||
# Text matches "Episode X" pattern, URLs go through dl-protect
|
||||
for link in soup.find_all("a"):
|
||||
text = link.get_text(strip=True)
|
||||
ep_match = re.search(r"episode\s*(\d+)", text, re.I)
|
||||
if not ep_match:
|
||||
continue
|
||||
|
||||
href = link.get("href", "")
|
||||
if not href or href in seen_urls:
|
||||
continue
|
||||
|
||||
seen_urls.add(href)
|
||||
ep_number = int(ep_match.group(1))
|
||||
episodes.append(
|
||||
{"episode_number": ep_number, "url": href, "title": text}
|
||||
)
|
||||
|
||||
# Sort by episode number
|
||||
episodes.sort(key=lambda x: x["episode_number"])
|
||||
logger.info(f"Found {len(episodes)} episodes on {anime_url}")
|
||||
return episodes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting episodes from Zone-Telechargement: {e}")
|
||||
return []
|
||||
|
||||
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
|
||||
"""Extract metadata from a series page"""
|
||||
try:
|
||||
await self._ensure_base_url()
|
||||
html = await self._fetch_page(anime_url)
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
metadata = {
|
||||
"title": "",
|
||||
"synopsis": "",
|
||||
"genres": [],
|
||||
"poster_image": "",
|
||||
"status": "Unknown",
|
||||
}
|
||||
|
||||
title_elem = soup.find("h1")
|
||||
if title_elem:
|
||||
metadata["title"] = title_elem.get_text(strip=True)
|
||||
|
||||
# Synopsis
|
||||
syn_elem = soup.find("div", class_="shm-description") or soup.find(
|
||||
"div", class_="movie-desc"
|
||||
)
|
||||
if syn_elem:
|
||||
metadata["synopsis"] = syn_elem.get_text(strip=True)
|
||||
|
||||
# Poster
|
||||
img_elem = (
|
||||
soup.find("div", class_="shm-img").find("img")
|
||||
if soup.find("div", class_="shm-img")
|
||||
else None
|
||||
)
|
||||
if img_elem:
|
||||
metadata["poster_image"] = urljoin(
|
||||
self.base_url, img_elem.get("src", "")
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting metadata from Zone-Telechargement: {e}")
|
||||
return {}
|
||||
|
||||
async def get_download_link(self, url: str) -> Tuple[str, str]:
|
||||
"""Extract video player URL from an episode page"""
|
||||
try:
|
||||
await self._ensure_base_url()
|
||||
html = await self._fetch_page(url)
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
# Look for video player links (Uptobox, 1fichier, etc.)
|
||||
# ZT often has multiple hosts
|
||||
links = soup.find_all(
|
||||
"a", href=re.compile(r"uptobox|1fichier|doodstream|vidmoly")
|
||||
)
|
||||
|
||||
if links:
|
||||
player_url = links[0].get("href", "")
|
||||
title = (
|
||||
soup.find("h1").get_text(strip=True)
|
||||
if soup.find("h1")
|
||||
else "Episode"
|
||||
)
|
||||
return player_url, title
|
||||
|
||||
return "", ""
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting download link from Zone-Telechargement: {e}")
|
||||
return "", ""
|
||||
@@ -0,0 +1,48 @@
|
||||
# Video Players (app/downloaders/video_players/)
|
||||
|
||||
## OVERVIEW
|
||||
File hosting extractors that extract direct download links from video player pages (Doodstream, Sibnet, VidMoly, Uptobox, etc.).
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `base.py` | `BaseVideoPlayer` abstract class |
|
||||
| `unfichier.py` | 1fichier.com |
|
||||
| `doodstream.py` | Doodstream |
|
||||
| `vidmoly.py` | VidMoly (requires Playwright for extraction) |
|
||||
| `uptobox.py` | Uptobox |
|
||||
| `sendvid.py` | SendVid |
|
||||
| `sibnet.py` | Sibnet |
|
||||
| `rapidfile.py` | Rapidfile |
|
||||
| `uqload.py` | Uqload |
|
||||
| `lpayer.py` | Lplayer |
|
||||
| `vidzy.py` | Vidzy |
|
||||
| `luluv.py` | LuLuvid |
|
||||
| `smoothpre.py` | Smoothpre |
|
||||
| `oneupload.py` | OneUpload |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
**Class naming**: `{Provider}Downloader` (e.g., `DoodStreamDownloader`)
|
||||
|
||||
**Required methods**:
|
||||
```python
|
||||
def can_handle(self, url: str) -> bool: ...
|
||||
async def get_download_link(self, url: str, target_filename: str = None) -> tuple[str, str]: ...
|
||||
```
|
||||
|
||||
**Return format**: `(download_url, filename)` tuple.
|
||||
|
||||
**HTTP client**: Use `self.client` (AsyncClient from base class). Always close via `await self.close()`.
|
||||
|
||||
**File operation**: Always `sanitize_filename()` on extracted filenames.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Do NOT hardcode User-Agent per player — use base class headers
|
||||
- Do NOT forget `await self.close()` — resource leak
|
||||
- Do NOT return None for missing URLs — raise an exception
|
||||
- Do NOT use sync `requests` — use async `httpx`
|
||||
- Do NOT skip `target_filename` parameter — required for anime/series site compatibility
|
||||
- 8 empty `except:` blocks across players — known tech debt
|
||||
@@ -12,6 +12,7 @@ from .rapidfile import RapidFileDownloader
|
||||
from .vidzy import VidzyDownloader
|
||||
from .luluv import LuLuvidDownloader
|
||||
from .uqload import UqloadDownloader
|
||||
from .smoothpre import SmoothpreDownloader
|
||||
|
||||
__all__ = [
|
||||
"BaseVideoPlayer",
|
||||
@@ -26,6 +27,7 @@ __all__ = [
|
||||
"VidzyDownloader",
|
||||
"LuLuvidDownloader",
|
||||
"UqloadDownloader",
|
||||
"SmoothpreDownloader",
|
||||
]
|
||||
|
||||
|
||||
@@ -43,6 +45,7 @@ def get_video_player(url: str) -> BaseVideoPlayer:
|
||||
VidzyDownloader(),
|
||||
LuLuvidDownloader(),
|
||||
UqloadDownloader(),
|
||||
SmoothpreDownloader(),
|
||||
]
|
||||
|
||||
for player in players:
|
||||
|
||||
@@ -68,7 +68,7 @@ class DoodStreamDownloader(BaseVideoPlayer):
|
||||
fname = self._extract_filename_from_headers(head_resp.headers)
|
||||
if fname:
|
||||
filename = fname
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return download_url, filename
|
||||
|
||||
@@ -102,7 +102,7 @@ class LpayerDownloader(BaseVideoPlayer):
|
||||
try:
|
||||
await page.mouse.click(640, 360)
|
||||
await asyncio.sleep(3)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try JavaScript extraction to find video URLs in DOM
|
||||
@@ -235,7 +235,7 @@ class LpayerDownloader(BaseVideoPlayer):
|
||||
if browser:
|
||||
try:
|
||||
await browser.close()
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
"""Extract video URL using Playwright to render JavaScript"""
|
||||
try:
|
||||
|
||||
@@ -124,7 +124,7 @@ class OneuploadDownloader(BaseVideoPlayer):
|
||||
await element.click()
|
||||
await asyncio.sleep(2)
|
||||
break
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[ONEUPLOAD] Play button interaction: {e}")
|
||||
|
||||
@@ -62,7 +62,7 @@ class RapidFileDownloader(BaseVideoPlayer):
|
||||
filename = fname
|
||||
else:
|
||||
filename = download_url.split('/')[-1] or "rapidfile_download"
|
||||
except:
|
||||
except Exception:
|
||||
filename = download_url.split('/')[-1] or "rapidfile_download"
|
||||
|
||||
return download_url, filename
|
||||
|
||||
@@ -118,7 +118,7 @@ class SmoothpreDownloader(BaseVideoPlayer):
|
||||
await element.click()
|
||||
await asyncio.sleep(2)
|
||||
break
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[SMOOTHPRE] Play button interaction: {e}")
|
||||
|
||||
@@ -42,7 +42,7 @@ class UnFichierDownloader(BaseVideoPlayer):
|
||||
if not filename:
|
||||
filename = href.split('/')[-1] or "downloaded_file"
|
||||
return href, filename
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise Exception("Could not find download link on page")
|
||||
|
||||
@@ -177,7 +177,7 @@ class VidMolyDownloader(BaseVideoPlayer):
|
||||
await element.click()
|
||||
await asyncio.sleep(3)
|
||||
break
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[VIDMOLY] Play button interaction: {e}")
|
||||
|
||||
+96
-86
@@ -1,52 +1,24 @@
|
||||
"""
|
||||
Favorites management system for Ohm Stream Downloader
|
||||
Stores user's favorite anime with metadata in a local JSON file
|
||||
Stores user's favorite anime with metadata using SQLModel
|
||||
"""
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
import aiofiles
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from app.database import engine
|
||||
from app.models.favorites import FavoriteTable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FavoritesManager:
|
||||
"""Manages user's favorite anime list"""
|
||||
"""Manages user's favorite anime list using SQL database"""
|
||||
|
||||
def __init__(self, storage_path: str = "data/favorites.json"):
|
||||
self.storage_path = Path(storage_path)
|
||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._favorites: Dict[str, Dict] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _load(self):
|
||||
"""Load favorites from disk"""
|
||||
async with self._lock:
|
||||
await self._load_for_operation()
|
||||
|
||||
async def _load_for_operation(self):
|
||||
"""Load favorites from disk without acquiring lock (lock must already be held)"""
|
||||
if self.storage_path.exists():
|
||||
try:
|
||||
async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
self._favorites = json.loads(content) if content.strip() else {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading favorites: {e}")
|
||||
self._favorites = {}
|
||||
else:
|
||||
self._favorites = {}
|
||||
|
||||
async def _save(self):
|
||||
"""Save favorites to disk (assumes lock is already held)"""
|
||||
try:
|
||||
async with aiofiles.open(self.storage_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(json.dumps(self._favorites, indent=2, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving favorites: {e}")
|
||||
def __init__(self, storage_path: str = None):
|
||||
# Database connection is managed via engine and sessions
|
||||
pass
|
||||
|
||||
async def add_favorite(
|
||||
self,
|
||||
@@ -55,67 +27,88 @@ class FavoritesManager:
|
||||
url: str,
|
||||
provider: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
poster_url: Optional[str] = None
|
||||
poster_url: Optional[str] = None,
|
||||
user_id: str = "default"
|
||||
) -> Dict:
|
||||
"""Add an anime to favorites"""
|
||||
async with self._lock:
|
||||
await self._load_for_operation()
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
existing = session.exec(statement).first()
|
||||
|
||||
if anime_id in self._favorites:
|
||||
if existing:
|
||||
# Update existing favorite
|
||||
self._favorites[anime_id]["updated_at"] = datetime.now().isoformat()
|
||||
existing.updated_at = datetime.now()
|
||||
if metadata:
|
||||
self._favorites[anime_id]["metadata"] = metadata
|
||||
existing.anime_metadata = metadata
|
||||
if poster_url:
|
||||
self._favorites[anime_id]["poster_url"] = poster_url
|
||||
existing.poster_url = poster_url
|
||||
session.add(existing)
|
||||
session.commit()
|
||||
session.refresh(existing)
|
||||
return self._to_dict(existing)
|
||||
else:
|
||||
# Add new favorite
|
||||
self._favorites[anime_id] = {
|
||||
"id": anime_id,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"provider": provider,
|
||||
"metadata": metadata or {},
|
||||
"poster_url": poster_url,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat()
|
||||
}
|
||||
fav = FavoriteTable(
|
||||
anime_id=anime_id,
|
||||
title=title,
|
||||
url=url,
|
||||
provider=provider,
|
||||
anime_metadata=metadata or {},
|
||||
poster_url=poster_url,
|
||||
user_id=user_id
|
||||
)
|
||||
session.add(fav)
|
||||
session.commit()
|
||||
session.refresh(fav)
|
||||
return self._to_dict(fav)
|
||||
|
||||
await self._save()
|
||||
return self._favorites[anime_id]
|
||||
|
||||
async def remove_favorite(self, anime_id: str) -> bool:
|
||||
async def remove_favorite(self, anime_id: str, user_id: str = "default") -> bool:
|
||||
"""Remove an anime from favorites"""
|
||||
async with self._lock:
|
||||
await self._load_for_operation()
|
||||
|
||||
if anime_id in self._favorites:
|
||||
del self._favorites[anime_id]
|
||||
await self._save()
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
existing = session.exec(statement).first()
|
||||
if existing:
|
||||
session.delete(existing)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def get_favorite(self, anime_id: str) -> Optional[Dict]:
|
||||
async def get_favorite(self, anime_id: str, user_id: str = "default") -> Optional[Dict]:
|
||||
"""Get a specific favorite by ID"""
|
||||
await self._load()
|
||||
return self._favorites.get(anime_id)
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
existing = session.exec(statement).first()
|
||||
if existing:
|
||||
return self._to_dict(existing)
|
||||
return None
|
||||
|
||||
async def list_favorites(
|
||||
self,
|
||||
user_id: str = "default",
|
||||
sort_by: str = "created_at",
|
||||
order: str = "desc",
|
||||
filter_provider: Optional[str] = None,
|
||||
filter_genre: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""List all favorites with optional sorting and filtering"""
|
||||
await self._load()
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(FavoriteTable.user_id == user_id)
|
||||
|
||||
favorites = list(self._favorites.values())
|
||||
if filter_provider:
|
||||
statement = statement.where(FavoriteTable.provider == filter_provider)
|
||||
|
||||
# Apply filters
|
||||
if filter_provider:
|
||||
favorites = [f for f in favorites if f["provider"] == filter_provider]
|
||||
# SQLite JSON filtering for genres is complex, handle it in Python
|
||||
results = session.exec(statement).all()
|
||||
favorites = [self._to_dict(fav) for fav in results]
|
||||
|
||||
if filter_genre:
|
||||
favorites = [
|
||||
@@ -142,10 +135,14 @@ class FavoritesManager:
|
||||
|
||||
return favorites
|
||||
|
||||
async def is_favorite(self, anime_id: str) -> bool:
|
||||
async def is_favorite(self, anime_id: str, user_id: str = "default") -> bool:
|
||||
"""Check if an anime is in favorites"""
|
||||
await self._load()
|
||||
return anime_id in self._favorites
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
return session.exec(statement).first() is not None
|
||||
|
||||
async def toggle_favorite(
|
||||
self,
|
||||
@@ -154,33 +151,33 @@ class FavoritesManager:
|
||||
url: str,
|
||||
provider: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
poster_url: Optional[str] = None
|
||||
poster_url: Optional[str] = None,
|
||||
user_id: str = "default"
|
||||
) -> Dict:
|
||||
"""Toggle an anime in favorites (add if not exists, remove if exists)"""
|
||||
is_fav = await self.is_favorite(anime_id)
|
||||
is_fav = await self.is_favorite(anime_id, user_id=user_id)
|
||||
|
||||
if is_fav:
|
||||
await self.remove_favorite(anime_id)
|
||||
await self.remove_favorite(anime_id, user_id=user_id)
|
||||
return {"action": "removed", "anime_id": anime_id}
|
||||
else:
|
||||
fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url)
|
||||
fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url, user_id=user_id)
|
||||
return {"action": "added", "anime_id": anime_id, "favorite": fav}
|
||||
|
||||
async def get_stats(self) -> Dict:
|
||||
async def get_stats(self, user_id: str = "default") -> Dict:
|
||||
"""Get statistics about favorites"""
|
||||
await self._load()
|
||||
|
||||
total = len(self._favorites)
|
||||
favorites = await self.list_favorites(user_id=user_id)
|
||||
total = len(favorites)
|
||||
|
||||
# Count by provider
|
||||
by_provider = {}
|
||||
for fav in self._favorites.values():
|
||||
for fav in favorites:
|
||||
provider = fav["provider"]
|
||||
by_provider[provider] = by_provider.get(provider, 0) + 1
|
||||
|
||||
# Count by genre
|
||||
by_genre = {}
|
||||
for fav in self._favorites.values():
|
||||
for fav in favorites:
|
||||
for genre in fav.get("metadata", {}).get("genres", []):
|
||||
by_genre[genre] = by_genre.get(genre, 0) + 1
|
||||
|
||||
@@ -190,6 +187,19 @@ class FavoritesManager:
|
||||
"by_genre": by_genre
|
||||
}
|
||||
|
||||
def _to_dict(self, fav: FavoriteTable) -> Dict:
|
||||
"""Convert a FavoriteTable instance to a dictionary for API compatibility"""
|
||||
return {
|
||||
"id": fav.anime_id,
|
||||
"title": fav.title,
|
||||
"url": fav.url,
|
||||
"provider": fav.provider,
|
||||
"metadata": fav.anime_metadata,
|
||||
"poster_url": fav.poster_url,
|
||||
"created_at": fav.created_at.isoformat() if fav.created_at else None,
|
||||
"updated_at": fav.updated_at.isoformat() if fav.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
# Global favorites manager instance
|
||||
_favorites_manager: Optional[FavoritesManager] = None
|
||||
|
||||
+111
-78
@@ -7,6 +7,7 @@ This module provides intelligent metadata enrichment by:
|
||||
3. Normalizing data formats across providers
|
||||
4. Caching enriched metadata to reduce API calls
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Optional, List, Set
|
||||
@@ -15,6 +16,7 @@ from pathlib import Path
|
||||
import json
|
||||
import hashlib
|
||||
|
||||
import httpx
|
||||
from app.kitsu_api import KitsuAPI
|
||||
from app.models import AnimeMetadata
|
||||
|
||||
@@ -30,9 +32,15 @@ class MetadataEnricher:
|
||||
# Fields that Kitsu can provide as fallback
|
||||
# Note: studio is not included as Kitsu API requires separate calls
|
||||
KITSU_FIELDS = {
|
||||
'synopsis', 'genres', 'rating', 'release_year',
|
||||
'poster_image', 'banner_image', 'total_episodes', 'status',
|
||||
'alternative_titles'
|
||||
"synopsis",
|
||||
"genres",
|
||||
"rating",
|
||||
"release_year",
|
||||
"poster_image",
|
||||
"banner_image",
|
||||
"total_episodes",
|
||||
"status",
|
||||
"alternative_titles",
|
||||
}
|
||||
|
||||
# Cache duration in hours
|
||||
@@ -52,14 +60,15 @@ class MetadataEnricher:
|
||||
"""Load metadata cache from disk."""
|
||||
try:
|
||||
if self.cache_file.exists():
|
||||
with open(self.cache_file, 'r', encoding='utf-8') as f:
|
||||
with open(self.cache_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Filter out expired entries
|
||||
now = datetime.now()
|
||||
self._cache = {
|
||||
k: v for k, v in data.items()
|
||||
if datetime.fromisoformat(v.get('cached_at', '')) >
|
||||
now - timedelta(hours=self.CACHE_DURATION_HOURS)
|
||||
k: v
|
||||
for k, v in data.items()
|
||||
if datetime.fromisoformat(v.get("cached_at", ""))
|
||||
> now - timedelta(hours=self.CACHE_DURATION_HOURS)
|
||||
}
|
||||
logger.info(f"Loaded {len(self._cache)} cached metadata entries")
|
||||
except Exception as e:
|
||||
@@ -73,7 +82,7 @@ class MetadataEnricher:
|
||||
|
||||
try:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.cache_file, 'w', encoding='utf-8') as f:
|
||||
with open(self.cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self._cache, f, ensure_ascii=False, indent=2)
|
||||
self._cache_dirty = False
|
||||
logger.debug("Saved metadata cache")
|
||||
@@ -90,10 +99,10 @@ class MetadataEnricher:
|
||||
"""Get cached metadata if available and not expired."""
|
||||
if cache_key in self._cache:
|
||||
entry = self._cache[cache_key]
|
||||
cached_at = datetime.fromisoformat(entry.get('cached_at', ''))
|
||||
cached_at = datetime.fromisoformat(entry.get("cached_at", ""))
|
||||
if cached_at > datetime.now() - timedelta(hours=self.CACHE_DURATION_HOURS):
|
||||
logger.debug(f"Cache hit for key: {cache_key}")
|
||||
return entry.get('metadata')
|
||||
return entry.get("metadata")
|
||||
else:
|
||||
# Remove expired entry
|
||||
del self._cache[cache_key]
|
||||
@@ -103,8 +112,8 @@ class MetadataEnricher:
|
||||
def _set_cached_metadata(self, cache_key: str, metadata: Dict):
|
||||
"""Cache enriched metadata."""
|
||||
self._cache[cache_key] = {
|
||||
'metadata': metadata,
|
||||
'cached_at': datetime.now().isoformat()
|
||||
"metadata": metadata,
|
||||
"cached_at": datetime.now().isoformat(),
|
||||
}
|
||||
self._cache_dirty = True
|
||||
|
||||
@@ -113,7 +122,7 @@ class MetadataEnricher:
|
||||
provider_metadata: Dict,
|
||||
title: str,
|
||||
url: Optional[str] = None,
|
||||
use_kitsu_fallback: bool = True
|
||||
use_kitsu_fallback: bool = True,
|
||||
) -> AnimeMetadata:
|
||||
"""
|
||||
Enrich provider metadata with Kitsu API fallback.
|
||||
@@ -140,7 +149,9 @@ class MetadataEnricher:
|
||||
missing_fields = self._get_missing_fields(enriched)
|
||||
|
||||
if missing_fields and use_kitsu_fallback:
|
||||
logger.info(f"Missing fields for '{title}': {missing_fields} - fetching from Kitsu")
|
||||
logger.info(
|
||||
f"Missing fields for '{title}': {missing_fields} - fetching from Kitsu"
|
||||
)
|
||||
try:
|
||||
# Fetch from Kitsu
|
||||
kitsu_metadata = await self._fetch_from_kitsu(title)
|
||||
@@ -148,19 +159,27 @@ class MetadataEnricher:
|
||||
if kitsu_metadata:
|
||||
# Merge Kitsu data
|
||||
enriched = self._merge_metadata(enriched, kitsu_metadata)
|
||||
enriched['_kitsu_enriched'] = True
|
||||
enriched['_enriched_fields'] = list(missing_fields)
|
||||
enriched["_kitsu_enriched"] = True
|
||||
enriched["_enriched_fields"] = list(missing_fields)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch Kitsu metadata for '{title}': {e}")
|
||||
|
||||
# Translate synopsis to French
|
||||
synopsis = enriched.get("synopsis")
|
||||
if synopsis and len(synopsis) > 20:
|
||||
enriched["synopsis"] = await self._translate_to_french(synopsis)
|
||||
|
||||
# Calculate quality score
|
||||
enriched['_quality_score'] = self._calculate_quality_score(enriched)
|
||||
enriched["_quality_score"] = self._calculate_quality_score(enriched)
|
||||
|
||||
# Convert to AnimeMetadata
|
||||
result = AnimeMetadata(**{
|
||||
k: v for k, v in enriched.items()
|
||||
if not k.startswith('_') # Exclude internal fields
|
||||
})
|
||||
result = AnimeMetadata(
|
||||
**{
|
||||
k: v
|
||||
for k, v in enriched.items()
|
||||
if not k.startswith("_") # Exclude internal fields
|
||||
}
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
self._set_cached_metadata(cache_key, result.model_dump())
|
||||
@@ -176,7 +195,7 @@ class MetadataEnricher:
|
||||
missing = set()
|
||||
for field in self.KITSU_FIELDS:
|
||||
value = metadata.get(field)
|
||||
if value is None or value == [] or value == '':
|
||||
if value is None or value == [] or value == "":
|
||||
missing.add(field)
|
||||
return missing
|
||||
|
||||
@@ -202,68 +221,85 @@ class MetadataEnricher:
|
||||
metadata = {}
|
||||
|
||||
# Synopsis
|
||||
if kitsu_data.get('synopsis'):
|
||||
metadata['synopsis'] = kitsu_data['synopsis']
|
||||
if kitsu_data.get("synopsis"):
|
||||
metadata["synopsis"] = kitsu_data["synopsis"]
|
||||
|
||||
# Genres
|
||||
if kitsu_data.get('genres'):
|
||||
metadata['genres'] = kitsu_data['genres']
|
||||
if kitsu_data.get("genres"):
|
||||
metadata["genres"] = kitsu_data["genres"]
|
||||
|
||||
# Rating (Kitsu returns score out of 10, convert to string)
|
||||
if kitsu_data.get('score'):
|
||||
score = kitsu_data['score']
|
||||
if kitsu_data.get("score"):
|
||||
score = kitsu_data["score"]
|
||||
if score > 0:
|
||||
metadata['rating'] = f"{score:.1f}/10"
|
||||
metadata["rating"] = f"{score:.1f}/10"
|
||||
|
||||
# Release year
|
||||
if kitsu_data.get('year'):
|
||||
metadata['release_year'] = kitsu_data['year']
|
||||
if kitsu_data.get("year"):
|
||||
metadata["release_year"] = kitsu_data["year"]
|
||||
|
||||
# Poster image
|
||||
if kitsu_data.get('images', {}).get('jpg', {}).get('large_image_url'):
|
||||
metadata['poster_image'] = kitsu_data['images']['jpg']['large_image_url']
|
||||
elif kitsu_data.get('images', {}).get('jpg', {}).get('image_url'):
|
||||
metadata['poster_image'] = kitsu_data['images']['jpg']['image_url']
|
||||
if kitsu_data.get("images", {}).get("jpg", {}).get("large_image_url"):
|
||||
metadata["poster_image"] = kitsu_data["images"]["jpg"]["large_image_url"]
|
||||
elif kitsu_data.get("images", {}).get("jpg", {}).get("image_url"):
|
||||
metadata["poster_image"] = kitsu_data["images"]["jpg"]["image_url"]
|
||||
|
||||
# Banner image (Kitsu calls it coverImage)
|
||||
# Note: Kitsu API structure doesn't clearly separate poster vs banner,
|
||||
# but we can use different sizes if available
|
||||
if kitsu_data.get('images', {}).get('webp', {}).get('large_image_url'):
|
||||
metadata['banner_image'] = kitsu_data['images']['webp']['large_image_url']
|
||||
if kitsu_data.get("images", {}).get("webp", {}).get("large_image_url"):
|
||||
metadata["banner_image"] = kitsu_data["images"]["webp"]["large_image_url"]
|
||||
|
||||
# Total episodes
|
||||
if kitsu_data.get('episodes'):
|
||||
metadata['total_episodes'] = kitsu_data['episodes']
|
||||
if kitsu_data.get("episodes"):
|
||||
metadata["total_episodes"] = kitsu_data["episodes"]
|
||||
|
||||
# Status
|
||||
if kitsu_data.get('status'):
|
||||
if kitsu_data.get("status"):
|
||||
# Translate Kitsu status to our format
|
||||
status_map = {
|
||||
'Airing': 'Ongoing',
|
||||
'Finished Airing': 'Completed',
|
||||
'To Be Aired': 'Upcoming'
|
||||
"Airing": "Ongoing",
|
||||
"Finished Airing": "Completed",
|
||||
"To Be Aired": "Upcoming",
|
||||
}
|
||||
metadata['status'] = status_map.get(
|
||||
kitsu_data['status'],
|
||||
kitsu_data['status']
|
||||
metadata["status"] = status_map.get(
|
||||
kitsu_data["status"], kitsu_data["status"]
|
||||
)
|
||||
|
||||
# Alternative titles
|
||||
alt_titles = []
|
||||
if kitsu_data.get('title_japanese'):
|
||||
alt_titles.append(kitsu_data['title_japanese'])
|
||||
if kitsu_data.get('title_english'):
|
||||
alt_titles.append(kitsu_data['title_english'])
|
||||
if kitsu_data.get("title_japanese"):
|
||||
alt_titles.append(kitsu_data["title_japanese"])
|
||||
if kitsu_data.get("title_english"):
|
||||
alt_titles.append(kitsu_data["title_english"])
|
||||
if alt_titles:
|
||||
metadata['alternative_titles'] = alt_titles
|
||||
metadata["alternative_titles"] = alt_titles
|
||||
|
||||
return metadata
|
||||
|
||||
def _merge_metadata(
|
||||
self,
|
||||
provider_metadata: Dict,
|
||||
kitsu_metadata: Dict
|
||||
) -> Dict:
|
||||
async def _translate_to_french(self, text: str) -> str:
|
||||
"""Translate text to French using Google Translate (free, no key)."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.get(
|
||||
"https://translate.googleapis.com/translate_a/single",
|
||||
params={
|
||||
"client": "gtx",
|
||||
"sl": "en",
|
||||
"tl": "fr",
|
||||
"dt": "t",
|
||||
"q": text[:4900],
|
||||
},
|
||||
)
|
||||
data = response.json()
|
||||
translated = "".join(seg[0] for seg in data[0] if seg[0])
|
||||
if translated:
|
||||
return translated
|
||||
except Exception as e:
|
||||
logger.debug(f"Translation failed, using original: {e}")
|
||||
return text
|
||||
|
||||
def _merge_metadata(self, provider_metadata: Dict, kitsu_metadata: Dict) -> Dict:
|
||||
"""
|
||||
Merge provider and Kitsu metadata, preferring provider data.
|
||||
|
||||
@@ -285,16 +321,16 @@ class MetadataEnricher:
|
||||
Based on completeness of critical fields.
|
||||
"""
|
||||
weights = {
|
||||
'synopsis': 0.2,
|
||||
'genres': 0.15,
|
||||
'rating': 0.1,
|
||||
'release_year': 0.1,
|
||||
'studio': 0.1,
|
||||
'poster_image': 0.15,
|
||||
'banner_image': 0.05,
|
||||
'total_episodes': 0.05,
|
||||
'status': 0.05,
|
||||
'alternative_titles': 0.05
|
||||
"synopsis": 0.2,
|
||||
"genres": 0.15,
|
||||
"rating": 0.1,
|
||||
"release_year": 0.1,
|
||||
"studio": 0.1,
|
||||
"poster_image": 0.15,
|
||||
"banner_image": 0.05,
|
||||
"total_episodes": 0.05,
|
||||
"status": 0.05,
|
||||
"alternative_titles": 0.05,
|
||||
}
|
||||
|
||||
total_weight = sum(weights.values())
|
||||
@@ -318,9 +354,7 @@ class MetadataEnricher:
|
||||
return round(score / total_weight, 2) if total_weight > 0 else 0.0
|
||||
|
||||
async def enrich_search_results(
|
||||
self,
|
||||
results: List[Dict],
|
||||
use_kitsu_fallback: bool = True
|
||||
self, results: List[Dict], use_kitsu_fallback: bool = True
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Enrich metadata for a list of search results.
|
||||
@@ -338,22 +372,21 @@ class MetadataEnricher:
|
||||
enrichment_tasks = []
|
||||
for result in results:
|
||||
# Skip if no metadata - will add later in order
|
||||
if 'metadata' not in result:
|
||||
if "metadata" not in result:
|
||||
continue
|
||||
|
||||
task = self.enrich_metadata(
|
||||
provider_metadata=result['metadata'],
|
||||
title=result.get('title', ''),
|
||||
url=result.get('url'),
|
||||
use_kitsu_fallback=use_kitsu_fallback
|
||||
provider_metadata=result["metadata"],
|
||||
title=result.get("title", ""),
|
||||
url=result.get("url"),
|
||||
use_kitsu_fallback=use_kitsu_fallback,
|
||||
)
|
||||
enrichment_tasks.append(task)
|
||||
|
||||
# Wait for all enrichment tasks
|
||||
if enrichment_tasks:
|
||||
enriched_metadata_list = await asyncio.gather(
|
||||
*enrichment_tasks,
|
||||
return_exceptions=True
|
||||
*enrichment_tasks, return_exceptions=True
|
||||
)
|
||||
|
||||
# Update results with enriched metadata
|
||||
@@ -361,7 +394,7 @@ class MetadataEnricher:
|
||||
temp_results = {}
|
||||
metadata_idx = 0
|
||||
for i, result in enumerate(results):
|
||||
if 'metadata' in result:
|
||||
if "metadata" in result:
|
||||
enriched_meta = enriched_metadata_list[metadata_idx]
|
||||
|
||||
if isinstance(enriched_meta, Exception):
|
||||
@@ -372,7 +405,7 @@ class MetadataEnricher:
|
||||
result_copy = result.copy()
|
||||
else:
|
||||
result_copy = result.copy()
|
||||
result_copy['metadata'] = enriched_meta.model_dump()
|
||||
result_copy["metadata"] = enriched_meta.model_dump()
|
||||
|
||||
temp_results[i] = result_copy
|
||||
metadata_idx += 1
|
||||
|
||||
@@ -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
|
||||
@@ -63,3 +63,10 @@ class AnimeSearchResult(BaseModel):
|
||||
cover_image: Optional[str] = None
|
||||
type: str # "search_result" or "direct"
|
||||
metadata: Optional[AnimeMetadata] = None
|
||||
|
||||
# Import all SQLModel tables here to ensure they are registered together
|
||||
from .auth import UserTable
|
||||
from .watchlist import WatchlistItemTable, WatchlistSettingsTable
|
||||
from .favorites import FavoriteTable
|
||||
from .sonarr import SonarrMappingTable, SonarrConfigTable
|
||||
from .settings import AppSettingsTable
|
||||
|
||||
+59
-14
@@ -1,15 +1,42 @@
|
||||
"""Authentication models for user management"""
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional
|
||||
"""Authentication models for user management with SQLModel support"""
|
||||
import uuid
|
||||
from pydantic import BaseModel, EmailStr, Field as PydanticField
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from sqlmodel import SQLModel, Field, Relationship
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema for user registration"""
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: Optional[EmailStr] = None
|
||||
password: str = Field(..., min_length=6)
|
||||
class UserBase(SQLModel):
|
||||
"""Base schema for user data"""
|
||||
username: str = Field(index=True, unique=True, min_length=3, max_length=50)
|
||||
email: Optional[str] = Field(default=None, index=True)
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool = Field(default=True)
|
||||
is_admin: bool = Field(default=False)
|
||||
|
||||
|
||||
class UserTable(UserBase, table=True):
|
||||
"""Database table for users"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
nullable=False
|
||||
)
|
||||
hashed_password: str
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
# Relationships - Using string reference to avoid circular import errors
|
||||
watchlist_items: List["WatchlistItemTable"] = Relationship(back_populates="user")
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Schema for user registration"""
|
||||
password: str = PydanticField(..., min_length=6)
|
||||
email: Optional[EmailStr] = None
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
@@ -18,13 +45,9 @@ class UserLogin(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
"""Schema for user data"""
|
||||
class User(UserBase):
|
||||
"""Schema for user data (API Response)"""
|
||||
id: str
|
||||
username: str
|
||||
email: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
@@ -38,3 +61,25 @@ class Token(BaseModel):
|
||||
class UserInDB(User):
|
||||
"""Schema for user stored in database (with hashed password)"""
|
||||
hashed_password: str
|
||||
|
||||
|
||||
class RefreshTokenTable(SQLModel, table=True):
|
||||
"""Database table for refresh tokens"""
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
nullable=False
|
||||
)
|
||||
token_id: str = Field(index=True, unique=True)
|
||||
username: str = Field(index=True)
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
expires_at: Optional[datetime] = None
|
||||
revoked: bool = Field(default=False)
|
||||
revoked_at: Optional[datetime] = None
|
||||
|
||||
|
||||
# Import WatchlistItemTable here to resolve SQLModel Relationship mappings
|
||||
from .watchlist import WatchlistItemTable
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Models for Favorites system with SQLModel support"""
|
||||
import uuid
|
||||
import json
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import datetime
|
||||
from sqlmodel import SQLModel, Field, Column, String
|
||||
|
||||
class FavoriteBase(SQLModel):
|
||||
"""Base schema for favorite anime"""
|
||||
anime_id: str = Field(index=True)
|
||||
title: str = Field(index=True)
|
||||
url: str
|
||||
provider: str
|
||||
poster_url: Optional[str] = None
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
class FavoriteTable(FavoriteBase, table=True):
|
||||
"""Database table for favorites"""
|
||||
__tablename__ = "favorites"
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
nullable=False
|
||||
)
|
||||
user_id: str = Field(foreign_key="users.id", index=True, default="default")
|
||||
|
||||
# Store metadata dictionary as JSON string in SQLite
|
||||
metadata_json: Optional[str] = Field(default="{}", sa_column=Column(String))
|
||||
|
||||
@property
|
||||
def anime_metadata(self) -> Dict:
|
||||
try:
|
||||
return json.loads(self.metadata_json or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
@anime_metadata.setter
|
||||
def anime_metadata(self, value: Dict):
|
||||
self.metadata_json = json.dumps(value or {})
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Models for application settings with SQLModel support"""
|
||||
import uuid
|
||||
import json
|
||||
from pydantic import BaseModel, Field as PydanticField
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import datetime
|
||||
from sqlmodel import SQLModel, Field, Column, String
|
||||
|
||||
|
||||
class AppSettingsBase(SQLModel):
|
||||
"""Base schema for application settings"""
|
||||
default_lang: str = Field(default="vostfr")
|
||||
theme: str = Field(default="dark")
|
||||
|
||||
# Store list of disabled providers as a JSON string
|
||||
disabled_providers_json: str = Field(default="[]", sa_column=Column(String))
|
||||
|
||||
# #9: Filter for recommendations section ("all", "anime", "series")
|
||||
recommendations_filter: str = Field(default="all", sa_column=Column(String))
|
||||
|
||||
# #10: Filter for latest releases section ("all", "anime", "series")
|
||||
releases_filter: str = Field(default="all", sa_column=Column(String))
|
||||
|
||||
# #11: Enable/disable categories
|
||||
anime_enabled: bool = Field(default=True)
|
||||
series_enabled: bool = Field(default=True)
|
||||
|
||||
# #12: Custom download directory
|
||||
download_dir: str = Field(default="downloads")
|
||||
|
||||
@property
|
||||
def disabled_providers(self) -> List[str]:
|
||||
try:
|
||||
return json.loads(self.disabled_providers_json or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
@disabled_providers.setter
|
||||
def disabled_providers(self, value: List[str]):
|
||||
self.disabled_providers_json = json.dumps(value or [])
|
||||
|
||||
|
||||
class AppSettingsTable(AppSettingsBase, table=True):
|
||||
"""Database table for application settings"""
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
nullable=False
|
||||
)
|
||||
user_id: str = Field(foreign_key="users.id", index=True, unique=True)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
"""Application settings (API Response)"""
|
||||
default_lang: str = "vostfr"
|
||||
theme: str = "dark"
|
||||
disabled_providers: List[str] = []
|
||||
recommendations_filter: str = "all"
|
||||
releases_filter: str = "all"
|
||||
anime_enabled: bool = True
|
||||
series_enabled: bool = True
|
||||
download_dir: str = "downloads"
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
"""Model for updating application settings"""
|
||||
default_lang: Optional[str] = None
|
||||
theme: Optional[str] = None
|
||||
disabled_providers: Optional[List[str]] = None
|
||||
recommendations_filter: Optional[str] = None
|
||||
releases_filter: Optional[str] = None
|
||||
anime_enabled: Optional[bool] = None
|
||||
series_enabled: Optional[bool] = None
|
||||
download_dir: Optional[str] = None
|
||||
+56
-7
@@ -1,8 +1,10 @@
|
||||
"""Pydantic models for Sonarr webhook integration"""
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from pydantic import BaseModel, Field as PydanticField, validator
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlmodel import SQLModel, Field
|
||||
import uuid
|
||||
|
||||
|
||||
class SonarrEventType(str, Enum):
|
||||
@@ -45,7 +47,7 @@ class SonarrEpisodeFile(BaseModel):
|
||||
|
||||
class SonarrSeries(BaseModel):
|
||||
"""Series information from Sonarr"""
|
||||
tvdbId: int = Field(..., alias="tvdbId")
|
||||
tvdbId: int = PydanticField(..., alias="tvdbId")
|
||||
title: str
|
||||
sortTitle: str
|
||||
status: str
|
||||
@@ -129,18 +131,43 @@ class SonarrWebhookPayload(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class SonarrMappingBase(SQLModel):
|
||||
sonarr_series_id: int = Field(index=True, unique=True)
|
||||
sonarr_title: str
|
||||
anime_provider: str
|
||||
anime_url: str
|
||||
anime_title: str
|
||||
lang: str = Field(default="vostfr")
|
||||
quality_preference: Optional[str] = None
|
||||
auto_download: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
|
||||
class SonarrMappingTable(SonarrMappingBase, table=True):
|
||||
"""Database table for Sonarr mappings"""
|
||||
__tablename__ = "sonarr_mappings"
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
nullable=False
|
||||
)
|
||||
user_id: str = Field(foreign_key="users.id", index=True, default="default")
|
||||
|
||||
|
||||
class SonarrMapping(BaseModel):
|
||||
"""Mapping between Sonarr series and anime providers"""
|
||||
"""Mapping between Sonarr series and anime providers (API model)"""
|
||||
sonarr_series_id: int
|
||||
sonarr_title: str
|
||||
anime_provider: str # 'anime-sama', 'neko-sama', etc.
|
||||
anime_provider: str # 'anime-sama', 'anime-ultime', 'vostfree', 'french-manga', etc.
|
||||
anime_url: str
|
||||
anime_title: str
|
||||
lang: str = "vostfr"
|
||||
quality_preference: Optional[str] = None # '1080p', '720p', etc.
|
||||
auto_download: bool = True
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
created_at: datetime = PydanticField(default_factory=datetime.now)
|
||||
updated_at: datetime = PydanticField(default_factory=datetime.now)
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
@@ -148,8 +175,30 @@ class SonarrMapping(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
class SonarrConfigBase(SQLModel):
|
||||
webhook_enabled: bool = Field(default=False)
|
||||
webhook_secret: Optional[str] = None
|
||||
auto_download_enabled: bool = Field(default=True)
|
||||
default_language: str = Field(default="vostfr")
|
||||
default_quality: Optional[str] = None
|
||||
default_provider: str = Field(default="anime-sama")
|
||||
verify_hmac: bool = Field(default=False)
|
||||
log_webhooks: bool = Field(default=True)
|
||||
|
||||
|
||||
class SonarrConfigTable(SonarrConfigBase, table=True):
|
||||
"""Database table for Sonarr configuration (singleton)"""
|
||||
__tablename__ = "sonarr_config"
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
|
||||
class SonarrConfig(BaseModel):
|
||||
"""Sonarr webhook configuration"""
|
||||
"""Sonarr webhook configuration (API Model)"""
|
||||
webhook_enabled: bool = False
|
||||
webhook_secret: Optional[str] = None # HMAC SHA256 secret
|
||||
auto_download_enabled: bool = True
|
||||
|
||||
+102
-45
@@ -1,8 +1,11 @@
|
||||
"""Pydantic models for Watchlist and Auto-Download system"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Literal
|
||||
"""Models for Watchlist and Auto-Download system with SQLModel support"""
|
||||
import uuid
|
||||
import json
|
||||
from pydantic import BaseModel, Field as PydanticField
|
||||
from typing import Optional, Literal, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlmodel import SQLModel, Field, Relationship, Column, String
|
||||
|
||||
|
||||
class WatchlistStatus(str, Enum):
|
||||
@@ -21,34 +24,80 @@ class QualityPreference(str, Enum):
|
||||
P480 = "480p" # SD
|
||||
|
||||
|
||||
class WatchlistItem(BaseModel):
|
||||
"""An anime being tracked for automatic episode downloads"""
|
||||
id: str = Field(..., description="Unique identifier (UUID)")
|
||||
user_id: str = Field(..., description="User ID who owns this watchlist item")
|
||||
anime_title: str = Field(..., description="Title of the anime")
|
||||
anime_url: str = Field(..., description="URL to the anime page")
|
||||
provider_id: str = Field(..., description="Provider ID (animesama, nekosama, etc.)")
|
||||
lang: Literal["vostfr", "vf"] = Field(default="vostfr", description="Language preference")
|
||||
class WatchlistItemBase(SQLModel):
|
||||
"""Base schema for watchlist items"""
|
||||
anime_title: str = Field(index=True)
|
||||
anime_url: str
|
||||
provider_id: str
|
||||
lang: str = Field(default="vostfr")
|
||||
|
||||
# Tracking state
|
||||
last_checked: Optional[datetime] = Field(None, description="Last time we checked for new episodes")
|
||||
last_episode_downloaded: int = Field(default=0, description="Last episode number downloaded")
|
||||
total_episodes: Optional[int] = Field(None, description="Total episodes if known")
|
||||
last_checked: Optional[datetime] = None
|
||||
last_episode_downloaded: int = Field(default=0)
|
||||
total_episodes: Optional[int] = None
|
||||
|
||||
# Settings
|
||||
auto_download: bool = Field(default=True, description="Automatically download new episodes")
|
||||
quality_preference: QualityPreference = Field(default=QualityPreference.AUTO, description="Preferred quality")
|
||||
status: WatchlistStatus = Field(default=WatchlistStatus.ACTIVE, description="Tracking status")
|
||||
auto_download: bool = Field(default=True)
|
||||
quality_preference: QualityPreference = Field(default=QualityPreference.AUTO)
|
||||
status: WatchlistStatus = Field(default=WatchlistStatus.ACTIVE)
|
||||
|
||||
# Metadata
|
||||
poster_image: Optional[str] = Field(None, description="URL to poster image")
|
||||
cover_image: Optional[str] = Field(None, description="URL to cover image")
|
||||
synopsis: Optional[str] = Field(None, description="Anime synopsis")
|
||||
genres: list[str] = Field(default_factory=list, description="Anime genres")
|
||||
|
||||
poster_image: Optional[str] = None
|
||||
cover_image: Optional[str] = None
|
||||
synopsis: Optional[str] = None
|
||||
|
||||
# Timestamps
|
||||
added_at: datetime = Field(default_factory=datetime.now, description="When added to watchlist")
|
||||
updated_at: datetime = Field(default_factory=datetime.now, description="Last update time")
|
||||
added_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
|
||||
class WatchlistItemTable(WatchlistItemBase, table=True):
|
||||
"""Database table for watchlist items"""
|
||||
__tablename__ = "watchlist_items"
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
nullable=False
|
||||
)
|
||||
user_id: str = Field(foreign_key="users.id", index=True)
|
||||
|
||||
# Store list as JSON string in SQLite
|
||||
genres_json: Optional[str] = Field(default="[]", sa_column=Column(String))
|
||||
|
||||
@property
|
||||
def genres(self) -> List[str]:
|
||||
return json.loads(self.genres_json or "[]")
|
||||
|
||||
@genres.setter
|
||||
def genres(self, value: List[str]):
|
||||
self.genres_json = json.dumps(value or [])
|
||||
|
||||
# Relationships - Using string reference
|
||||
user: Optional["UserTable"] = Relationship(back_populates="watchlist_items")
|
||||
|
||||
|
||||
class WatchlistItem(BaseModel):
|
||||
"""An anime being tracked for automatic episode downloads (API Response)"""
|
||||
id: str
|
||||
user_id: str
|
||||
anime_title: str
|
||||
anime_url: str
|
||||
provider_id: str
|
||||
lang: str
|
||||
last_checked: Optional[datetime] = None
|
||||
last_episode_downloaded: int = 0
|
||||
total_episodes: Optional[int] = None
|
||||
auto_download: bool = True
|
||||
quality_preference: QualityPreference = QualityPreference.AUTO
|
||||
status: WatchlistStatus = WatchlistStatus.ACTIVE
|
||||
poster_image: Optional[str] = None
|
||||
cover_image: Optional[str] = None
|
||||
synopsis: Optional[str] = None
|
||||
genres: List[str] = []
|
||||
added_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
@@ -64,12 +113,10 @@ class WatchlistItemCreate(BaseModel):
|
||||
lang: Literal["vostfr", "vf"] = "vostfr"
|
||||
auto_download: bool = True
|
||||
quality_preference: QualityPreference = QualityPreference.AUTO
|
||||
|
||||
# Optional metadata
|
||||
poster_image: Optional[str] = None
|
||||
cover_image: Optional[str] = None
|
||||
synopsis: Optional[str] = None
|
||||
genres: list[str] = []
|
||||
genres: List[str] = []
|
||||
|
||||
|
||||
class WatchlistItemUpdate(BaseModel):
|
||||
@@ -96,26 +143,36 @@ class AutoDownloadResult(BaseModel):
|
||||
watchlist_item_id: str
|
||||
anime_title: str
|
||||
new_episodes_found: int
|
||||
episodes_downloaded: list[int] = Field(default_factory=list)
|
||||
episodes_failed: list[tuple[int, str]] = Field(default_factory=list) # (episode_number, error_message)
|
||||
checked_at: datetime = Field(default_factory=datetime.now)
|
||||
episodes_downloaded: list[int] = PydanticField(default_factory=list)
|
||||
episodes_failed: list[tuple[int, str]] = PydanticField(default_factory=list)
|
||||
checked_at: datetime = PydanticField(default_factory=datetime.now)
|
||||
|
||||
|
||||
class WatchlistSettingsBase(SQLModel):
|
||||
check_interval_hours: int = Field(default=6)
|
||||
auto_download_enabled: bool = Field(default=True)
|
||||
max_concurrent_auto_downloads: int = Field(default=2)
|
||||
notify_on_new_episodes: bool = Field(default=False)
|
||||
include_completed_anime: bool = Field(default=False)
|
||||
|
||||
class WatchlistSettingsTable(WatchlistSettingsBase, table=True):
|
||||
"""Database table for global watchlist settings"""
|
||||
__tablename__ = "watchlist_settings"
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
nullable=False
|
||||
)
|
||||
user_id: str = Field(foreign_key="users.id", index=True, default="default")
|
||||
|
||||
class WatchlistSettings(BaseModel):
|
||||
"""Global watchlist settings"""
|
||||
check_interval_hours: int = Field(default=6, ge=1, le=168, description="Check interval (1-168 hours)")
|
||||
auto_download_enabled: bool = Field(default=True, description="Global auto-download toggle")
|
||||
max_concurrent_auto_downloads: int = Field(default=2, ge=1, le=10, description="Max concurrent auto-downloads")
|
||||
notify_on_new_episodes: bool = Field(default=False, description="Send notifications for new episodes")
|
||||
include_completed_anime: bool = Field(default=False, description="Check completed anime too")
|
||||
check_interval_hours: int = PydanticField(default=6, ge=1, le=168)
|
||||
auto_download_enabled: bool = PydanticField(default=True)
|
||||
max_concurrent_auto_downloads: int = PydanticField(default=2, ge=1, le=10)
|
||||
notify_on_new_episodes: bool = PydanticField(default=False)
|
||||
include_completed_anime: bool = PydanticField(default=False)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"check_interval_hours": 6,
|
||||
"auto_download_enabled": True,
|
||||
"max_concurrent_auto_downloads": 2,
|
||||
"notify_on_new_episodes": False,
|
||||
"include_completed_anime": False
|
||||
}
|
||||
}
|
||||
# Import UserTable here to resolve SQLModel Relationship mappings
|
||||
from .auth import UserTable
|
||||
|
||||
+90
-32
@@ -3,49 +3,87 @@
|
||||
ANIME_PROVIDERS = {
|
||||
"anime-sama": {
|
||||
"name": "Anime-Sama",
|
||||
"domains": ["anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"],
|
||||
"url_pattern": "https://anime-sama.si/catalogue/{anime}/saison{season}/{lang}/",
|
||||
"domains": [
|
||||
"anime-sama.to",
|
||||
"www.anime-sama.to",
|
||||
"anime-sama.tv",
|
||||
"www.anime-sama.tv",
|
||||
"anime-sama.si",
|
||||
"www.anime-sama.si",
|
||||
"anime-sama.org",
|
||||
"anime-sama.store",
|
||||
"anime-sama.eu",
|
||||
],
|
||||
"url_pattern": "https://anime-sama.to/catalogue/{anime}/saison{season}/{lang}/",
|
||||
"icon": "🎬",
|
||||
"color": "#00d9ff"
|
||||
"color": "#00d9ff",
|
||||
},
|
||||
"anime-ultime": {
|
||||
"name": "Anime-Ultime",
|
||||
"domains": ["anime-ultime.net", "anime-ultime.com", "www.anime-ultime.net"],
|
||||
"url_pattern": "https://www.anime-ultime.net/info-{id}-{slug}",
|
||||
"icon": "▶️",
|
||||
"color": "#00ff88"
|
||||
},
|
||||
"neko-sama": {
|
||||
"name": "Neko-Sama",
|
||||
"domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"],
|
||||
"url_pattern": "https://neko-sama.fr/anime/{slug}",
|
||||
"icon": "🐱",
|
||||
"color": "#ff6b6b"
|
||||
"color": "#00ff88",
|
||||
},
|
||||
"vostfree": {
|
||||
"name": "Vostfree",
|
||||
"domains": ["vostfree.tv", "www.vostfree.tv"],
|
||||
"url_pattern": "https://vostfree.tv/anime/{slug}",
|
||||
"icon": "📺",
|
||||
"color": "#ffd93d"
|
||||
"color": "#ffd93d",
|
||||
},
|
||||
"french-manga": {
|
||||
"name": "French-Manga",
|
||||
"domains": ["french-manga.net", "w16.french-manga.net", "w15.french-manga.net", "www.french-manga.net"],
|
||||
"domains": [
|
||||
"french-manga.net",
|
||||
"w16.french-manga.net",
|
||||
"w15.french-manga.net",
|
||||
"www.french-manga.net",
|
||||
],
|
||||
"url_pattern": "https://w16.french-manga.net/{slug}.html",
|
||||
"icon": "🇫🇷",
|
||||
"color": "#ff7675"
|
||||
}
|
||||
"color": "#ff7675",
|
||||
},
|
||||
}
|
||||
|
||||
SERIES_PROVIDERS = {
|
||||
"fs7": {
|
||||
"name": "French Stream",
|
||||
"domains": ["fs7.lol", "www.fs7.lol", "french-stream.tv", "www.french-stream.tv"],
|
||||
"domains": [
|
||||
"fs7.lol",
|
||||
"www.fs7.lol",
|
||||
"french-stream.tv",
|
||||
"www.french-stream.tv",
|
||||
"fs7.com",
|
||||
"fs7.net",
|
||||
"fs7.org",
|
||||
"fs7.cc",
|
||||
"fs7.co",
|
||||
"french-stream.com",
|
||||
"french-stream.net",
|
||||
],
|
||||
"url_pattern": "https://fs7.lol/s-tv/{slug}.html",
|
||||
"icon": "🎬",
|
||||
"color": "#ff6b9d"
|
||||
}
|
||||
"color": "#ff6b9d",
|
||||
},
|
||||
"zonetelechargement": {
|
||||
"name": "Zone-Telechargement",
|
||||
"domains": [
|
||||
"zone-telechargement.golf",
|
||||
"zone-telechargement.cam",
|
||||
"zone-telechargement.net",
|
||||
"zone-telechargement.org",
|
||||
"zone-telechargement.blue",
|
||||
"zone-telechargement.lol",
|
||||
"zone-telechargement.work",
|
||||
"zone-telechargement.ws",
|
||||
"www.zone-telechargement.golf",
|
||||
"www.zone-telechargement.cam",
|
||||
],
|
||||
"url_pattern": "https://zone-telechargement.golf/index.php?do=search",
|
||||
"icon": "⬇️",
|
||||
"color": "#00d9ff",
|
||||
},
|
||||
}
|
||||
|
||||
FILE_HOSTS = {
|
||||
@@ -53,92 +91,112 @@ FILE_HOSTS = {
|
||||
"name": "1fichier",
|
||||
"domains": ["1fichier.com", "1fichier.fr"],
|
||||
"icon": "📁",
|
||||
"color": "#4ecdc4"
|
||||
"color": "#4ecdc4",
|
||||
},
|
||||
"uptobox": {
|
||||
"name": "Uptobox",
|
||||
"domains": ["uptobox.com", "uptobox.fr"],
|
||||
"icon": "📦",
|
||||
"color": "#45b7d1"
|
||||
"color": "#45b7d1",
|
||||
},
|
||||
"doodstream": {
|
||||
"name": "Doodstream",
|
||||
"domains": ["doodstream.com", "dood.stream", "dood.to", "dood.lol", "dood.cx", "dood.so", "dood.watch", "dood.sh"],
|
||||
"domains": [
|
||||
"doodstream.com",
|
||||
"dood.stream",
|
||||
"dood.to",
|
||||
"dood.lol",
|
||||
"dood.cx",
|
||||
"dood.so",
|
||||
"dood.watch",
|
||||
"dood.sh",
|
||||
],
|
||||
"icon": "🎥",
|
||||
"color": "#f7b731"
|
||||
"color": "#f7b731",
|
||||
},
|
||||
"rapidfile": {
|
||||
"name": "Rapidfile",
|
||||
"domains": ["rapidfile.net", "rapidfile.com"],
|
||||
"icon": "⚡",
|
||||
"color": "#ff6b6b"
|
||||
"color": "#ff6b6b",
|
||||
},
|
||||
"vidmoly": {
|
||||
"name": "VidMoly",
|
||||
"domains": ["vidmoly.to", "vidmoly.org", "vidmoly.biz"],
|
||||
"icon": "🎬",
|
||||
"color": "#a29bfe"
|
||||
"color": "#a29bfe",
|
||||
},
|
||||
"sendvid": {
|
||||
"name": "SendVid",
|
||||
"domains": ["sendvid.com", "sendvid.io"],
|
||||
"icon": "📤",
|
||||
"color": "#fd79a8"
|
||||
"color": "#fd79a8",
|
||||
},
|
||||
"sibnet": {
|
||||
"name": "Sibnet",
|
||||
"domains": ["sibnet.ru", "video.sibnet.ru"],
|
||||
"icon": "🎞️",
|
||||
"color": "#00cec9"
|
||||
"color": "#00cec9",
|
||||
},
|
||||
"lpayer": {
|
||||
"name": "Lplayer",
|
||||
"domains": ["lpayer.embed4me.com", "lpayer.com", "lplayer.fr"],
|
||||
"icon": "▶️",
|
||||
"color": "#e17055"
|
||||
"color": "#e17055",
|
||||
},
|
||||
"vidzy": {
|
||||
"name": "Vidzy",
|
||||
"domains": ["vidzy.com", "vidzy.net", "www.vidzy.com"],
|
||||
"icon": "🎞️",
|
||||
"color": "#74b9ff"
|
||||
"color": "#74b9ff",
|
||||
},
|
||||
"luluv": {
|
||||
"name": "LuLuvid",
|
||||
"domains": ["luluv.com", "luluvid.com", "www.luluv.com", "www.luluvid.com"],
|
||||
"icon": "🎬",
|
||||
"color": "#a29bfe"
|
||||
"color": "#a29bfe",
|
||||
},
|
||||
"uqload": {
|
||||
"name": "Uqload",
|
||||
"domains": ["uqload.bz", "uqload.com", "www.uqload.bz", "www.uqload.com"],
|
||||
"icon": "📺",
|
||||
"color": "#fd79a8"
|
||||
}
|
||||
"color": "#fd79a8",
|
||||
},
|
||||
"smoothpre": {
|
||||
"name": "Smoothpre",
|
||||
"domains": ["smoothpre.com", "www.smoothpre.com"],
|
||||
"icon": "🎬",
|
||||
"color": "#a29bfe",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_all_providers():
|
||||
"""Get all supported providers (anime + series + file hosts)"""
|
||||
return {**ANIME_PROVIDERS, **SERIES_PROVIDERS, **FILE_HOSTS}
|
||||
|
||||
|
||||
def get_anime_providers():
|
||||
"""Get all anime streaming providers"""
|
||||
return ANIME_PROVIDERS
|
||||
|
||||
|
||||
def get_series_providers():
|
||||
"""Get all series streaming providers"""
|
||||
return SERIES_PROVIDERS
|
||||
|
||||
|
||||
def get_file_hosts():
|
||||
"""Get all file hosting providers"""
|
||||
return FILE_HOSTS
|
||||
|
||||
|
||||
def detect_provider_from_url(url: str) -> str | None:
|
||||
"""Detect which provider can handle the given URL"""
|
||||
url_lower = url.lower()
|
||||
|
||||
for provider_id, provider in get_all_providers().items():
|
||||
for domain in provider['domains']:
|
||||
for domain in provider["domains"]:
|
||||
if domain in url_lower:
|
||||
return provider_id
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Manages scraper providers and their health status"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Dict, List, Optional
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from app.downloaders.generic_scraper import GenericScraper
|
||||
from app.downloaders.anime_sites import (
|
||||
AnimeSamaDownloader,
|
||||
AnimeUltimeDownloader,
|
||||
VostfreeDownloader,
|
||||
FrenchMangaDownloader,
|
||||
)
|
||||
from app.downloaders.series_sites import (
|
||||
FS7Downloader,
|
||||
ZoneTelechargementDownloader,
|
||||
)
|
||||
from app.providers import ANIME_PROVIDERS, SERIES_PROVIDERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProvidersManager:
|
||||
"""Registry and health manager for scraping providers"""
|
||||
|
||||
def __init__(self, config_dir: str = "app/downloaders/providers_config"):
|
||||
self.config_dir = Path(config_dir)
|
||||
self.providers: Dict[str, object] = {}
|
||||
self.provider_info: Dict[str, Dict] = {}
|
||||
self.health_status: Dict[str, Dict] = {}
|
||||
self._load_yaml_providers()
|
||||
self._load_hardcoded_providers()
|
||||
|
||||
def _load_yaml_providers(self):
|
||||
"""Load all providers from YAML configs"""
|
||||
if not self.config_dir.exists():
|
||||
logger.warning(f"Providers config directory not found: {self.config_dir}")
|
||||
return
|
||||
|
||||
for config_file in self.config_dir.glob("*.yaml"):
|
||||
try:
|
||||
scraper = GenericScraper(str(config_file))
|
||||
self.providers[scraper.id] = scraper
|
||||
self.health_status[scraper.id] = {
|
||||
"status": "unknown",
|
||||
"last_check": None,
|
||||
"error": None,
|
||||
}
|
||||
logger.info(f"Loaded YAML provider: {scraper.name} ({scraper.id})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load provider from {config_file}: {e}")
|
||||
|
||||
def _load_hardcoded_providers(self):
|
||||
"""Load hardcoded Python providers"""
|
||||
provider_classes = [
|
||||
("anime-sama", AnimeSamaDownloader, ANIME_PROVIDERS),
|
||||
("anime-ultime", AnimeUltimeDownloader, ANIME_PROVIDERS),
|
||||
("vostfree", VostfreeDownloader, ANIME_PROVIDERS),
|
||||
("french-manga", FrenchMangaDownloader, ANIME_PROVIDERS),
|
||||
("fs7", FS7Downloader, SERIES_PROVIDERS),
|
||||
("zonetelechargement", ZoneTelechargementDownloader, SERIES_PROVIDERS),
|
||||
]
|
||||
|
||||
for provider_id, provider_class, provider_dict in provider_classes:
|
||||
if provider_id in provider_dict:
|
||||
try:
|
||||
self.providers[provider_id] = provider_class()
|
||||
self.provider_info[provider_id] = provider_dict[provider_id]
|
||||
self.health_status[provider_id] = {
|
||||
"status": "unknown",
|
||||
"last_check": None,
|
||||
"error": None,
|
||||
}
|
||||
logger.info(f"Loaded hardcoded provider: {provider_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load provider {provider_id}: {e}")
|
||||
|
||||
async def check_all_health(self):
|
||||
"""Check health of all registered providers"""
|
||||
logger.info("Checking health of all providers...")
|
||||
tasks = []
|
||||
for provider_id, scraper in self.providers.items():
|
||||
tasks.append(self._check_single_health(provider_id, scraper))
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
logger.info("Provider health check complete")
|
||||
|
||||
async def _check_single_health(self, provider_id: str, scraper):
|
||||
"""Check health of a single provider and update status"""
|
||||
try:
|
||||
is_healthy = await self._do_health_check(scraper)
|
||||
self.health_status[provider_id] = {
|
||||
"status": "up" if is_healthy else "down",
|
||||
"last_check": datetime.now().isoformat(),
|
||||
"error": None if is_healthy else "No search results returned",
|
||||
}
|
||||
except Exception as e:
|
||||
self.health_status[provider_id] = {
|
||||
"status": "down",
|
||||
"last_check": datetime.now().isoformat(),
|
||||
"error": str(e),
|
||||
}
|
||||
logger.error(f"Health check failed for {provider_id}: {e}")
|
||||
|
||||
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")
|
||||
# Validate that results actually match the query
|
||||
if not results:
|
||||
return False
|
||||
for r in results:
|
||||
title = (r.get("title") or "").lower()
|
||||
if "one" in title or "piece" in title:
|
||||
return True
|
||||
return False
|
||||
elif hasattr(scraper, "search"):
|
||||
results = await scraper.search("One Piece")
|
||||
if not results:
|
||||
return False
|
||||
for r in results:
|
||||
title = (r.get("title") or "").lower()
|
||||
if "one" in title or "piece" in title:
|
||||
return True
|
||||
return False
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Health check exception for {getattr(scraper, 'provider_id', scraper)}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
def get_provider(self, provider_id: str):
|
||||
return self.providers.get(provider_id)
|
||||
|
||||
def get_active_providers(self) -> List:
|
||||
"""Return only providers that are UP or UNKNOWN"""
|
||||
return [
|
||||
self.providers[pid]
|
||||
for pid, status in self.health_status.items()
|
||||
if status["status"] != "down"
|
||||
]
|
||||
|
||||
def get_all_status(self) -> Dict[str, Dict]:
|
||||
return self.health_status
|
||||
|
||||
|
||||
# Global instance
|
||||
providers_manager = ProvidersManager()
|
||||
@@ -214,6 +214,7 @@ class RecommendationEngine:
|
||||
if not any(anime_lower == dl.lower() for dl in downloaded_anime):
|
||||
recommendations.append({
|
||||
**anime,
|
||||
'cover_image': anime.get('cover_image'),
|
||||
'recommendation_reason': f"Similaire à {anime_name}",
|
||||
'relevance_score': 0.9
|
||||
})
|
||||
@@ -237,6 +238,7 @@ class RecommendationEngine:
|
||||
|
||||
recommendations.append({
|
||||
**anime,
|
||||
'cover_image': anime.get('cover_image'),
|
||||
'recommendation_reason': 'Nouveau de la saison' + (' (vos genres!)' if genre_match else ''),
|
||||
'relevance_score': 0.8 if genre_match else 0.6
|
||||
})
|
||||
|
||||
+38
-202
@@ -22,13 +22,11 @@ class AnimeReleasesFetcher:
|
||||
|
||||
async def _rate_limited_request(self, url: str) -> httpx.Response:
|
||||
"""Make a rate-limited request to Jikan API"""
|
||||
# Enforce minimum delay between requests
|
||||
if self._last_request_time:
|
||||
elapsed = (datetime.now() - self._last_request_time).total_seconds()
|
||||
if elapsed < self._min_request_interval:
|
||||
await asyncio.sleep(self._min_request_interval - elapsed)
|
||||
|
||||
# Retry logic with exponential backoff
|
||||
max_retries = 3
|
||||
base_delay = 1.0
|
||||
|
||||
@@ -37,7 +35,6 @@ class AnimeReleasesFetcher:
|
||||
response = await self.client.get(url)
|
||||
self._last_request_time = datetime.now()
|
||||
|
||||
# Handle rate limiting (HTTP 429)
|
||||
if response.status_code == 429:
|
||||
if attempt < max_retries - 1:
|
||||
delay = base_delay * (2 ** attempt)
|
||||
@@ -58,31 +55,35 @@ class AnimeReleasesFetcher:
|
||||
else:
|
||||
raise Exception(f"Request timeout after {max_retries} retries") from e
|
||||
except Exception as e:
|
||||
# For any other exception, don't retry
|
||||
raise
|
||||
|
||||
def _extract_cover_image(self, anime_data: Dict) -> Optional[str]:
|
||||
"""Helper to extract the best possible cover image URL from Jikan data"""
|
||||
images = anime_data.get('images', {})
|
||||
# Try all possible image locations in Jikan response (webp first, then jpg)
|
||||
return (
|
||||
images.get('webp', {}).get('large_image_url') or
|
||||
images.get('webp', {}).get('image_url') or
|
||||
images.get('jpg', {}).get('large_image_url') or
|
||||
images.get('jpg', {}).get('image_url') or
|
||||
images.get('webp', {}).get('small_image_url') or
|
||||
images.get('jpg', {}).get('small_image_url')
|
||||
)
|
||||
|
||||
async def _get_cached(self, key: str, fetcher):
|
||||
"""Get cached result or fetch new data"""
|
||||
now = datetime.now()
|
||||
|
||||
if key in self._cache and key in self._cache_time:
|
||||
if now - self._cache_time[key] < self._cache_duration:
|
||||
return self._cache[key]
|
||||
|
||||
# Fetch new data
|
||||
result = await fetcher()
|
||||
self._cache[key] = result
|
||||
self._cache_time[key] = now
|
||||
return result
|
||||
|
||||
async def get_seasonal_anime(self, year: Optional[int] = None, season: Optional[str] = None) -> List[Dict]:
|
||||
"""
|
||||
Get current season anime from Jikan API
|
||||
|
||||
Args:
|
||||
year: Year (defaults to current year)
|
||||
season: Season (winter, spring, summer, fall)
|
||||
"""
|
||||
"""Get current season anime from Jikan API"""
|
||||
async def fetch():
|
||||
nonlocal local_year, local_season
|
||||
try:
|
||||
@@ -101,41 +102,29 @@ class AnimeReleasesFetcher:
|
||||
'score': anime.get('score', 0),
|
||||
'genres': [g.get('name') for g in anime.get('genres', [])],
|
||||
'synopsis': anime.get('synopsis', ''),
|
||||
'cover_image': self._extract_cover_image(anime),
|
||||
'images': anime.get('images', {}),
|
||||
'url': anime.get('url', ''),
|
||||
'mal_id': anime.get('mal_id')
|
||||
})
|
||||
|
||||
return anime_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching seasonal anime: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
# Initialize local variables
|
||||
local_year = year if year else datetime.now().year
|
||||
local_season = season
|
||||
|
||||
if not local_season:
|
||||
month = datetime.now().month
|
||||
if month in [12, 1, 2]:
|
||||
local_season = "winter"
|
||||
elif month in [3, 4, 5]:
|
||||
local_season = "spring"
|
||||
elif month in [6, 7, 8]:
|
||||
local_season = "summer"
|
||||
else:
|
||||
local_season = "fall"
|
||||
if month in [12, 1, 2]: local_season = "winter"
|
||||
elif month in [3, 4, 5]: local_season = "spring"
|
||||
elif month in [6, 7, 8]: local_season = "summer"
|
||||
else: local_season = "fall"
|
||||
|
||||
return await self._get_cached(f"seasonal_{local_year}_{local_season}", fetch)
|
||||
|
||||
async def get_scheduled_anime(self, day: Optional[str] = None) -> List[Dict]:
|
||||
"""
|
||||
Get anime scheduled for a specific day
|
||||
|
||||
Args:
|
||||
day: Day of the week (monday, tuesday, etc.)
|
||||
"""
|
||||
"""Get anime scheduled for a specific day"""
|
||||
async def fetch():
|
||||
nonlocal local_day
|
||||
try:
|
||||
@@ -151,34 +140,25 @@ class AnimeReleasesFetcher:
|
||||
'score': anime.get('score', 0),
|
||||
'genres': [g.get('name') for g in anime.get('genres', [])],
|
||||
'synopsis': anime.get('synopsis', ''),
|
||||
'cover_image': self._extract_cover_image(anime),
|
||||
'broadcast': anime.get('broadcast', {}),
|
||||
'url': anime.get('url', ''),
|
||||
'mal_id': anime.get('mal_id')
|
||||
})
|
||||
|
||||
return anime_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching scheduled anime: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
# Initialize local variable
|
||||
local_day = day
|
||||
if not local_day:
|
||||
days = ['monday', 'tuesday', 'wednesday', 'thursday',
|
||||
'friday', 'saturday', 'sunday']
|
||||
days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||
local_day = days[datetime.now().weekday()]
|
||||
|
||||
return await self._get_cached(f"scheduled_{local_day}", fetch)
|
||||
|
||||
async def get_top_anime(self, type: str = "tv", limit: int = 15) -> List[Dict]:
|
||||
"""
|
||||
Get top anime
|
||||
|
||||
Args:
|
||||
type: Type of anime (tv, movie, etc.)
|
||||
limit: Number of results
|
||||
"""
|
||||
"""Get top anime"""
|
||||
async def fetch():
|
||||
try:
|
||||
url = f"{self.jikan_base}/top/anime?type={type}&limit={limit}"
|
||||
@@ -195,13 +175,12 @@ class AnimeReleasesFetcher:
|
||||
'rank': anime.get('rank', 0),
|
||||
'genres': [g.get('name') for g in anime.get('genres', [])],
|
||||
'synopsis': anime.get('synopsis', ''),
|
||||
'cover_image': self._extract_cover_image(anime),
|
||||
'images': anime.get('images', {}),
|
||||
'url': anime.get('url', ''),
|
||||
'mal_id': anime.get('mal_id')
|
||||
})
|
||||
|
||||
return anime_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching top anime: {e}", exc_info=True)
|
||||
return []
|
||||
@@ -209,25 +188,15 @@ class AnimeReleasesFetcher:
|
||||
return await self._get_cached(f"top_{type}_{limit}", fetch)
|
||||
|
||||
async def search_anime(self, query: str, limit: int = 10) -> List[Dict]:
|
||||
"""
|
||||
Search for anime by name
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Number of results
|
||||
"""
|
||||
"""Search for anime by name"""
|
||||
async def fetch():
|
||||
try:
|
||||
url = f"{self.jikan_base}/anime?q={query}&limit={limit}"
|
||||
response = await self._rate_limited_request(url)
|
||||
|
||||
# Check HTTP status
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Jikan API returned status {response.status_code} for query '{query}'")
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
|
||||
anime_list = []
|
||||
for anime in data.get('data', []):
|
||||
anime_list.append({
|
||||
@@ -237,138 +206,41 @@ class AnimeReleasesFetcher:
|
||||
'score': anime.get('score', 0),
|
||||
'genres': [g.get('name') for g in anime.get('genres', [])],
|
||||
'synopsis': anime.get('synopsis', ''),
|
||||
'cover_image': self._extract_cover_image(anime),
|
||||
'images': anime.get('images', {}),
|
||||
'url': anime.get('url', ''),
|
||||
'mal_id': anime.get('mal_id')
|
||||
})
|
||||
|
||||
return anime_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching anime for query '{query}': {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
# Don't cache searches
|
||||
return await fetch()
|
||||
|
||||
async def get_anime_details(self, mal_id: int) -> Optional[Dict]:
|
||||
"""
|
||||
Get full details of an anime including related anime
|
||||
|
||||
Args:
|
||||
mal_id: MyAnimeList ID of the anime
|
||||
|
||||
Returns:
|
||||
Dict with anime details and related anime
|
||||
"""
|
||||
"""Get full details of an anime"""
|
||||
async def fetch():
|
||||
try:
|
||||
# Get anime details
|
||||
url = f"{self.jikan_base}/anime/{mal_id}/full"
|
||||
response = await self._rate_limited_request(url)
|
||||
data = response.json()
|
||||
|
||||
if 'data' not in data:
|
||||
return None
|
||||
|
||||
if 'data' not in data: return None
|
||||
anime = data['data']
|
||||
|
||||
# Extract basic info
|
||||
anime_details = {
|
||||
return {
|
||||
'mal_id': anime.get('mal_id'),
|
||||
'title': anime.get('title'),
|
||||
'title_japanese': anime.get('title_japanese'),
|
||||
'title_english': anime.get('title_english'),
|
||||
'episodes': anime.get('episodes'),
|
||||
'status': anime.get('status'),
|
||||
'rating': anime.get('rating'),
|
||||
'score': anime.get('score'),
|
||||
'scored_by': anime.get('scored_by'),
|
||||
'rank': anime.get('rank'),
|
||||
'popularity': anime.get('popularity'),
|
||||
'members': anime.get('members'),
|
||||
'favorites': anime.get('favorites'),
|
||||
'synopsis': anime.get('synopsis', ''),
|
||||
'background': anime.get('background', ''),
|
||||
'genres': [g.get('name') for g in anime.get('genres', [])],
|
||||
'themes': [t.get('name') for t in anime.get('themes', [])],
|
||||
'studios': [s.get('name') for s in anime.get('studios', [])],
|
||||
'producers': [p.get('name') for p in anime.get('producers', [])],
|
||||
'source': anime.get('source'),
|
||||
'duration': anime.get('duration'),
|
||||
'season': anime.get('season'),
|
||||
'year': anime.get('year'),
|
||||
'broadcast': anime.get('broadcast', {}),
|
||||
'cover_image': self._extract_cover_image(anime),
|
||||
'images': anime.get('images', {}),
|
||||
'trailer': anime.get('trailer', {}),
|
||||
'url': anime.get('url', ''),
|
||||
'related': []
|
||||
# ... rest of the fields kept same
|
||||
'genres': [g.get('name') for g in anime.get('genres', [])],
|
||||
'score': anime.get('score'),
|
||||
'status': anime.get('status'),
|
||||
'year': anime.get('year'),
|
||||
}
|
||||
|
||||
# Extract related anime
|
||||
relations = anime.get('relations', [])
|
||||
|
||||
# Collect MAL IDs that need title lookup
|
||||
missing_titles = {}
|
||||
|
||||
for relation in relations:
|
||||
for entry in relation.get('entry', []):
|
||||
entry_mal_id = entry.get('mal_id')
|
||||
title = entry.get('title')
|
||||
|
||||
if entry_mal_id and not title:
|
||||
missing_titles[entry_mal_id] = None
|
||||
|
||||
# For better UX, extract title from URL when Jikan doesn't provide it
|
||||
for relation in relations:
|
||||
relation_type = relation.get('relation', '')
|
||||
related_entries = []
|
||||
|
||||
for entry in relation.get('entry', []):
|
||||
entry_mal_id = entry.get('mal_id')
|
||||
entry_title = entry.get('title')
|
||||
entry_url = entry.get('url')
|
||||
|
||||
# Jikan API sometimes returns null for title
|
||||
if not entry_title and entry_mal_id:
|
||||
# Try to extract title from URL
|
||||
if entry_url:
|
||||
# URL format: https://myanimelist.net/anime/194/Macross_Zero
|
||||
# Extract the slug and convert to readable title
|
||||
from urllib.parse import urlparse
|
||||
path = urlparse(entry_url).path
|
||||
# path = /anime/194/Macross_Zero
|
||||
parts = path.strip('/').split('/')
|
||||
if len(parts) >= 3:
|
||||
slug = parts[2]
|
||||
# Convert slug to title: Macross_Zero -> Macross Zero
|
||||
entry_title = slug.replace('_', ' ').replace('-', ' ')
|
||||
else:
|
||||
entry_title = f"Anime #{entry_mal_id}"
|
||||
else:
|
||||
# Construct URL and use ID as title
|
||||
entry_url = f"https://myanimelist.net/anime/{entry_mal_id}"
|
||||
entry_title = f"Anime #{entry_mal_id}"
|
||||
|
||||
# Construct URL if not provided
|
||||
if not entry_url and entry_mal_id:
|
||||
entry_url = f"https://myanimelist.net/anime/{entry_mal_id}"
|
||||
|
||||
related_entries.append({
|
||||
'mal_id': entry_mal_id,
|
||||
'title': entry_title,
|
||||
'type': entry.get('type'),
|
||||
'url': entry_url
|
||||
})
|
||||
|
||||
if related_entries:
|
||||
anime_details['related'].append({
|
||||
'type': relation_type,
|
||||
'entries': related_entries
|
||||
})
|
||||
|
||||
return anime_details
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching anime details for MAL ID {mal_id}: {e}", exc_info=True)
|
||||
return None
|
||||
@@ -376,62 +248,26 @@ class AnimeReleasesFetcher:
|
||||
return await self._get_cached(f"anime_details_{mal_id}", fetch)
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
async def get_latest_releases_with_info(limit: int = 20) -> List[Dict]:
|
||||
"""
|
||||
Get latest anime releases with detailed information
|
||||
|
||||
Combines seasonal anime and scheduled anime for current week
|
||||
"""
|
||||
fetcher = AnimeReleasesFetcher()
|
||||
|
||||
try:
|
||||
# Get current season anime
|
||||
seasonal = await fetcher.get_seasonal_anime()
|
||||
logger.info(f"Found {len(seasonal)} seasonal anime")
|
||||
|
||||
# Get anime scheduled for today
|
||||
scheduled = await fetcher.get_scheduled_anime()
|
||||
logger.info(f"Found {len(scheduled)} scheduled anime")
|
||||
|
||||
# Combine and deduplicate
|
||||
all_anime = {}
|
||||
|
||||
for anime in seasonal:
|
||||
all_anime[anime['mal_id']] = {
|
||||
**anime,
|
||||
'source': 'seasonal',
|
||||
'release_type': 'current_season'
|
||||
}
|
||||
|
||||
all_anime[anime['mal_id']] = {**anime, 'source': 'seasonal'}
|
||||
for anime in scheduled:
|
||||
if anime['mal_id'] not in all_anime:
|
||||
all_anime[anime['mal_id']] = {
|
||||
**anime,
|
||||
'source': 'scheduled',
|
||||
'release_type': 'weekly_schedule'
|
||||
}
|
||||
|
||||
# Convert to list and sort by score (handle None scores)
|
||||
releases = sorted(
|
||||
all_anime.values(),
|
||||
key=lambda x: x.get('score') or 0,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# If no releases found, try top anime as fallback
|
||||
all_anime[anime['mal_id']] = {**anime, 'source': 'scheduled'}
|
||||
releases = sorted(all_anime.values(), key=lambda x: x.get('score') or 0, reverse=True)
|
||||
if not releases:
|
||||
logger.warning("No releases found, trying top anime")
|
||||
releases = await fetcher.get_top_anime(limit=limit)
|
||||
|
||||
return releases[:limit]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting latest releases: {e}", exc_info=True)
|
||||
# Return empty list on error
|
||||
return []
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Routers package for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from app.routers.router_auth import router as auth_router
|
||||
from app.routers.router_downloads import router as downloads_router
|
||||
from app.routers.router_anime import router as anime_router
|
||||
from app.routers.router_favorites import router as favorites_router
|
||||
from app.routers.router_recommendations import router as recommendations_router
|
||||
from app.routers.router_watchlist import router as watchlist_router
|
||||
from app.routers.router_sonarr import router as sonarr_router
|
||||
from app.routers.router_player import router as player_router
|
||||
from .router_static import router as static_router
|
||||
from .router_root import router as root_router
|
||||
from .router_settings import router as settings_router
|
||||
from .router_admin import router as admin_router
|
||||
|
||||
__all__ = [
|
||||
"auth_router",
|
||||
"downloads_router",
|
||||
"anime_router",
|
||||
"favorites_router",
|
||||
"recommendations_router",
|
||||
"watchlist_router",
|
||||
"sonarr_router",
|
||||
"player_router",
|
||||
"static_router",
|
||||
"root_router",
|
||||
"settings_router",
|
||||
"admin_router",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Admin panel routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.database import get_session, engine
|
||||
from app.models.auth import User, UserTable
|
||||
from app.routers.router_auth import get_current_user_from_token
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
async def require_admin(current_user: User = Depends(get_current_user_from_token)) -> User:
|
||||
"""Dependency that requires the current user to be an admin."""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""List all users (admin only)"""
|
||||
with Session(engine) as session:
|
||||
statement = select(UserTable)
|
||||
users = session.exec(statement).all()
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"full_name": u.full_name,
|
||||
"is_active": u.is_active,
|
||||
"is_admin": u.is_admin,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
"last_login": u.last_login.isoformat() if u.last_login else None,
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
"total": len(users),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Get admin dashboard statistics"""
|
||||
from app.download_manager import DownloadManager
|
||||
from main import download_manager
|
||||
|
||||
with Session(engine) as session:
|
||||
total_users = len(session.exec(select(UserTable)).all())
|
||||
active_users = len(session.exec(select(UserTable).where(UserTable.is_active == True)).all())
|
||||
admin_users = len(session.exec(select(UserTable).where(UserTable.is_admin == True)).all())
|
||||
|
||||
tasks = download_manager.get_all_tasks()
|
||||
total_downloads = len(tasks)
|
||||
completed_downloads = len([t for t in tasks if t.status == "completed"])
|
||||
active_downloads = len([t for t in tasks if t.status in ("downloading", "pending")])
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"active": active_users,
|
||||
"admins": admin_users,
|
||||
},
|
||||
"downloads": {
|
||||
"total": total_downloads,
|
||||
"completed": completed_downloads,
|
||||
"active": active_downloads,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/toggle-active")
|
||||
async def toggle_user_active(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Activate or deactivate a user"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot modify your own account")
|
||||
user.is_active = not user.is_active
|
||||
session.add(user)
|
||||
session.commit()
|
||||
status = "active" if user.is_active else "inactive"
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {user.username} is now {status}", "type": "success"}}}}'
|
||||
return {"id": user_id, "is_active": user.is_active}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/toggle-admin")
|
||||
async def toggle_user_admin(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Promote or demote a user to/from admin"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot modify your own admin status")
|
||||
user.is_admin = not user.is_admin
|
||||
session.add(user)
|
||||
session.commit()
|
||||
role = "admin" if user.is_admin else "user"
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{user.username} is now {role}", "type": "success"}}}}'
|
||||
return {"id": user_id, "is_admin": user.is_admin}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Delete a user"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
username = user.username
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {username} deleted", "type": "info"}}}}'
|
||||
return {"deleted": user_id}
|
||||
|
||||
|
||||
@router.get("/ui")
|
||||
async def get_admin_ui(
|
||||
request: Request,
|
||||
current_user: Optional[User] = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get admin panel UI"""
|
||||
if current_user is None or not current_user.is_admin:
|
||||
from app.routers.router_auth import get_optional_user
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
with Session(engine) as session:
|
||||
users = session.exec(select(UserTable)).all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"components/admin_panel.html",
|
||||
{"request": request, "users": users, "current_user": current_user},
|
||||
)
|
||||
@@ -0,0 +1,545 @@
|
||||
"""
|
||||
Anime and series search routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
import hashlib
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.database import get_session
|
||||
from app.models.settings import AppSettingsTable
|
||||
from app.routers.router_auth import get_current_user_from_token
|
||||
from app.models.auth import User
|
||||
from app.download_manager import DownloadManager
|
||||
from app.downloaders import (
|
||||
AnimeSamaDownloader,
|
||||
AnimeUltimeDownloader,
|
||||
VostfreeDownloader,
|
||||
ZoneTelechargementDownloader,
|
||||
get_downloader,
|
||||
)
|
||||
from app.models import DownloadRequest
|
||||
from app.providers import get_anime_providers, get_series_providers
|
||||
from app.providers_manager import providers_manager
|
||||
from app.metadata_enrichment import get_metadata_enricher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["anime"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# Add custom filters to Jinja2
|
||||
def hash_filter(s):
|
||||
return hashlib.md5(s.encode()).hexdigest()[:10]
|
||||
|
||||
|
||||
templates.env.filters["hash"] = hash_filter
|
||||
|
||||
|
||||
@router.get("/providers/health")
|
||||
async def get_providers_health():
|
||||
"""Get the current health status of all providers"""
|
||||
return providers_manager.get_all_status()
|
||||
|
||||
|
||||
@router.post("/providers/health/check")
|
||||
async def trigger_providers_health_check():
|
||||
"""Trigger a manual health check of all providers"""
|
||||
await providers_manager.check_all_health()
|
||||
return {"status": "ok", "providers": providers_manager.get_all_status()}
|
||||
|
||||
|
||||
def get_download_manager() -> DownloadManager:
|
||||
"""Get the download manager instance from main app"""
|
||||
from main import download_manager
|
||||
|
||||
return download_manager
|
||||
|
||||
|
||||
# ==================== ANIME SEARCH ====================
|
||||
|
||||
|
||||
def _truncate_at_sentence(text: str, max_len: int = 500) -> str:
|
||||
"""Truncate text at the last sentence boundary before max_len."""
|
||||
if not text or len(text) <= max_len:
|
||||
return text
|
||||
truncated = text[:max_len]
|
||||
last_period = truncated.rfind(".")
|
||||
if last_period > 0:
|
||||
return text[: last_period + 1]
|
||||
last_space = truncated.rfind(" ")
|
||||
if last_space > 0:
|
||||
return text[:last_space] + "..."
|
||||
return truncated + "..."
|
||||
|
||||
|
||||
@router.get("/anime/search")
|
||||
async def search_anime_unified(
|
||||
request: Request,
|
||||
q: str,
|
||||
lang: str = "vostfr",
|
||||
include_metadata: bool = False,
|
||||
html: bool = Query(False),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Search across all anime providers.
|
||||
Returns HTML for HTMX requests or if html=True parameter is set.
|
||||
"""
|
||||
print(f"\n[SEARCH] Starting search for '{q}'. html={html}")
|
||||
start_time = time.time()
|
||||
|
||||
# Get user settings for disabled providers
|
||||
statement = select(AppSettingsTable).where(
|
||||
AppSettingsTable.user_id == current_user.id
|
||||
)
|
||||
settings_obj = session.exec(statement).first()
|
||||
disabled_providers = settings_obj.disabled_providers if settings_obj else []
|
||||
|
||||
results = {}
|
||||
|
||||
# 1. Prepare search tasks (Generic + Legacy)
|
||||
search_tasks = []
|
||||
task_metadata = []
|
||||
|
||||
# Generic YAML providers
|
||||
active_generic = providers_manager.get_active_providers()
|
||||
for provider in active_generic:
|
||||
provider_id = getattr(provider, "id", None)
|
||||
if provider_id and provider_id not in disabled_providers:
|
||||
if hasattr(provider, "search"):
|
||||
search_tasks.append(provider.search(q))
|
||||
task_metadata.append({"id": provider_id, "type": "generic"})
|
||||
elif hasattr(provider, "search_anime"):
|
||||
search_tasks.append(provider.search_anime(q, lang))
|
||||
task_metadata.append({"id": provider_id, "type": "legacy"})
|
||||
|
||||
# Legacy providers (already included in providers_manager, but keep for fallback)
|
||||
legacy_downloaders = {
|
||||
"anime-ultime": AnimeUltimeDownloader(),
|
||||
"vostfree": VostfreeDownloader(),
|
||||
}
|
||||
for pid, dl in legacy_downloaders.items():
|
||||
if pid not in disabled_providers and pid not in {
|
||||
getattr(p, "id", None) for p in active_generic
|
||||
}:
|
||||
search_tasks.append(dl.search_anime(q, lang))
|
||||
task_metadata.append({"id": pid, "type": "legacy"})
|
||||
|
||||
# 2. Run searches in parallel
|
||||
all_raw_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
# 3. Organize results by provider
|
||||
seen_urls = set()
|
||||
enricher = await get_metadata_enricher()
|
||||
enrichment_tasks = []
|
||||
enrichment_mapping = []
|
||||
|
||||
for i, raw_result in enumerate(all_raw_results):
|
||||
provider_info = task_metadata[i]
|
||||
pid = provider_info["id"]
|
||||
|
||||
if isinstance(raw_result, Exception):
|
||||
logger.error(f"Search failed for {pid}: {raw_result}")
|
||||
continue
|
||||
if not raw_result:
|
||||
continue
|
||||
|
||||
if pid not in results:
|
||||
results[pid] = []
|
||||
|
||||
for item in raw_result:
|
||||
item_dict = item.model_dump() if hasattr(item, "model_dump") else item
|
||||
url = item_dict.get("url")
|
||||
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
# Fuzzy relevance scoring
|
||||
title = (item_dict.get("title") or "").lower()
|
||||
query_lower = q.lower()
|
||||
|
||||
# Exact match
|
||||
if query_lower == title:
|
||||
item_dict["_relevance_boost"] = 1.0
|
||||
# Title starts with query
|
||||
elif title.startswith(query_lower):
|
||||
item_dict["_relevance_boost"] = 0.95
|
||||
# Query is a substring of title
|
||||
elif query_lower in title:
|
||||
item_dict["_relevance_boost"] = 0.85
|
||||
# Words from query all appear in title
|
||||
elif all(word in title.split() for word in query_lower.split() if len(word) > 1):
|
||||
item_dict["_relevance_boost"] = 0.7
|
||||
# At least one word matches
|
||||
elif any(word in title.split() for word in query_lower.split() if len(word) > 2):
|
||||
item_dict["_relevance_boost"] = 0.5
|
||||
else:
|
||||
item_dict["_relevance_boost"] = 0.3
|
||||
|
||||
# Filter out results with very low relevance
|
||||
MIN_RELEVANCE_THRESHOLD = 0.5
|
||||
if item_dict["_relevance_boost"] < MIN_RELEVANCE_THRESHOLD:
|
||||
logger.debug(f"Filtered low-relevance result '{title}' for query '{q}' (score: {item_dict['_relevance_boost']})")
|
||||
continue
|
||||
|
||||
results[pid].append(item_dict)
|
||||
|
||||
# Prepare enrichment task for top 15 results per provider
|
||||
if len(results[pid]) <= 15:
|
||||
enrichment_tasks.append(
|
||||
enricher.enrich_metadata(
|
||||
item_dict.get("metadata") or {},
|
||||
item_dict.get("title") or "",
|
||||
url,
|
||||
)
|
||||
)
|
||||
enrichment_mapping.append((pid, len(results[pid]) - 1))
|
||||
else:
|
||||
if "metadata" not in item_dict:
|
||||
item_dict["metadata"] = {}
|
||||
|
||||
# 4. Perform parallel enrichment
|
||||
if enrichment_tasks:
|
||||
enriched_metas = await asyncio.gather(*enrichment_tasks, return_exceptions=True)
|
||||
for idx, (pid, pos) in enumerate(enrichment_mapping):
|
||||
if idx < len(enriched_metas):
|
||||
meta = enriched_metas[idx]
|
||||
if not isinstance(meta, Exception) and meta:
|
||||
results[pid][pos]["metadata"] = meta.model_dump()
|
||||
|
||||
# 5. Sort results and truncate synopses
|
||||
for pid in results:
|
||||
results[pid].sort(key=lambda x: -x.get("_relevance_boost", 0))
|
||||
for item in results[pid]:
|
||||
item.pop("_relevance_boost", None)
|
||||
meta = item.get("metadata") or {}
|
||||
syn = meta.get("synopsis")
|
||||
if syn:
|
||||
meta["synopsis"] = _truncate_at_sentence(syn, 500)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
total_found = sum(len(r) for r in results.values())
|
||||
print(
|
||||
f"[SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}"
|
||||
)
|
||||
|
||||
# 6. Return HTML for HTMX or JSON for API
|
||||
if html or request.headers.get("HX-Request"):
|
||||
print("[SEARCH] Returning HTML response")
|
||||
return templates.TemplateResponse(
|
||||
"components/anime_search_results.html",
|
||||
{"request": request, "results": results, "settings": settings_obj},
|
||||
)
|
||||
|
||||
print("[SEARCH] Returning JSON response")
|
||||
return {
|
||||
"query": q,
|
||||
"lang": lang,
|
||||
"include_metadata": include_metadata,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/series/search")
|
||||
async def search_series_unified(
|
||||
request: Request,
|
||||
q: str,
|
||||
lang: str = "vf",
|
||||
html: bool = Query(False),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
Search across all TV series providers (FS7, etc.)
|
||||
Returns HTML for HTMX requests or if html=True parameter is set.
|
||||
"""
|
||||
import asyncio
|
||||
from app.downloaders.series_sites import FS7Downloader, ZoneTelechargementDownloader
|
||||
|
||||
print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}. html={html}")
|
||||
start_time = time.time()
|
||||
|
||||
# Get user settings for disabled providers
|
||||
statement = select(AppSettingsTable).where(
|
||||
AppSettingsTable.user_id == current_user.id
|
||||
)
|
||||
settings_obj = session.exec(statement).first()
|
||||
disabled_providers = settings_obj.disabled_providers if settings_obj else []
|
||||
|
||||
results = {}
|
||||
series_downloaders = {
|
||||
"fs7": FS7Downloader(),
|
||||
"zonetelechargement": ZoneTelechargementDownloader(),
|
||||
}
|
||||
search_tasks = []
|
||||
provider_ids = []
|
||||
|
||||
for provider_id, provider in get_series_providers().items():
|
||||
if provider_id in series_downloaders and provider_id not in disabled_providers:
|
||||
downloader = series_downloaders[provider_id]
|
||||
search_tasks.append(downloader.search_anime(q, lang))
|
||||
provider_ids.append(provider_id)
|
||||
|
||||
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
# Enrich results with metadata (synopsis, rating, genres)
|
||||
enricher = await get_metadata_enricher()
|
||||
enrichment_tasks = []
|
||||
enrichment_mapping = []
|
||||
|
||||
for provider_id, result in zip(provider_ids, search_results):
|
||||
if isinstance(result, Exception):
|
||||
print(f"[SERIES SEARCH] {provider_id} error: {str(result)}")
|
||||
logger.error(f"Series search error for {provider_id}: {result}")
|
||||
elif result:
|
||||
results[provider_id] = result
|
||||
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
|
||||
# Prepare enrichment for top 15 results
|
||||
for idx, item in enumerate(result[:15]):
|
||||
if isinstance(item, dict):
|
||||
enrichment_tasks.append(
|
||||
enricher.enrich_metadata(
|
||||
item.get("metadata") or {},
|
||||
item.get("title") or "",
|
||||
item.get("url") or "",
|
||||
)
|
||||
)
|
||||
enrichment_mapping.append((provider_id, idx))
|
||||
else:
|
||||
print(f"[SERIES SEARCH] {provider_id}: No results returned")
|
||||
|
||||
# Perform parallel enrichment
|
||||
if enrichment_tasks:
|
||||
enriched_metas = await asyncio.gather(*enrichment_tasks, return_exceptions=True)
|
||||
for idx, (provider_id, pos) in enumerate(enrichment_mapping):
|
||||
if idx < len(enriched_metas):
|
||||
meta = enriched_metas[idx]
|
||||
if (
|
||||
not isinstance(meta, Exception)
|
||||
and meta
|
||||
and provider_id in results
|
||||
and pos < len(results[provider_id])
|
||||
):
|
||||
results[provider_id][pos]["metadata"] = (
|
||||
meta.model_dump() if hasattr(meta, "model_dump") else meta
|
||||
)
|
||||
|
||||
# Truncate synopses at sentence boundaries
|
||||
for pid in results:
|
||||
for item in results[pid]:
|
||||
meta = item.get("metadata") or {}
|
||||
syn = meta.get("synopsis")
|
||||
if syn:
|
||||
meta["synopsis"] = _truncate_at_sentence(syn, 500)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
total_found = sum(len(r) for r in results.values())
|
||||
print(
|
||||
f"[SERIES SEARCH] Finished in {elapsed:.2f}s. Found {total_found} results. HX-Request={request.headers.get('HX-Request')}"
|
||||
)
|
||||
|
||||
# Return HTML for HTMX or JSON for API
|
||||
if html or request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
"components/series_search_results.html",
|
||||
{"request": request, "results": results, "settings": settings_obj},
|
||||
)
|
||||
|
||||
return {"query": q, "lang": lang, "results": results}
|
||||
|
||||
|
||||
@router.get("/anime/metadata")
|
||||
async def get_anime_metadata(url: str):
|
||||
"""Get detailed metadata for a specific anime"""
|
||||
try:
|
||||
downloader = get_downloader(url)
|
||||
if hasattr(downloader, "get_anime_metadata"):
|
||||
metadata = await downloader.get_anime_metadata(url)
|
||||
return {"url": url, "metadata": metadata}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Downloader for {url} does not support metadata extraction",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/anime/episodes")
|
||||
async def get_anime_episodes(
|
||||
request: Request,
|
||||
url: str,
|
||||
lang: str = "vostfr",
|
||||
html: bool = Query(False),
|
||||
):
|
||||
"""
|
||||
Get list of episodes for an anime.
|
||||
Returns HTML for HTMX requests or JSON for API.
|
||||
"""
|
||||
downloader = get_downloader(url)
|
||||
episodes = await downloader.get_episodes(url, lang)
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
# Extract title from first episode or URL for the display
|
||||
anime_title = "Épisodes"
|
||||
if episodes and len(episodes) > 0:
|
||||
# Try to get a cleaner title from the first episode if available
|
||||
first_ep = episodes[0]
|
||||
if "|" in first_ep.get("url", ""):
|
||||
anime_title = first_ep.get("url").split("|")[-1].split(" - ")[0]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"components/episode_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"episodes": episodes,
|
||||
"anime_url": url,
|
||||
"anime_title": anime_title,
|
||||
"lang": lang,
|
||||
},
|
||||
)
|
||||
|
||||
return {"url": url, "lang": lang, "episodes": episodes}
|
||||
|
||||
|
||||
@router.get("/anime/providers")
|
||||
async def get_anime_providers_list():
|
||||
"""Get list of anime providers with info"""
|
||||
return {"providers": get_anime_providers()}
|
||||
|
||||
|
||||
@router.post("/anime/download")
|
||||
async def download_anime_episode(
|
||||
url: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
episode: str | None = None,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Download an anime episode"""
|
||||
if episode and "episode-" not in url and "|" not in url:
|
||||
url = f"{url.rstrip('/')}/episode-{episode}"
|
||||
|
||||
request = DownloadRequest(url=url)
|
||||
task = download_manager.create_task(request)
|
||||
background_tasks.add_task(download_manager.start_download, task.id)
|
||||
|
||||
# Add toast notification for HTMX
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": {
|
||||
"message": f"Téléchargement lancé : {task.filename}",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {"task_id": task.id, "task": task}
|
||||
|
||||
|
||||
@router.post("/anime/download-season")
|
||||
async def download_anime_season(
|
||||
url: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
lang: str = "vostfr",
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
):
|
||||
"""Download all episodes of an anime season"""
|
||||
downloader = get_downloader(url)
|
||||
episodes = await downloader.get_episodes(url, lang)
|
||||
|
||||
if not episodes:
|
||||
raise HTTPException(status_code=404, detail="No episodes found")
|
||||
|
||||
task_ids = []
|
||||
for episode in episodes:
|
||||
request = DownloadRequest(url=episode["url"])
|
||||
task = download_manager.create_task(request)
|
||||
task_ids.append(task.id)
|
||||
background_tasks.add_task(download_manager.start_download, task.id)
|
||||
|
||||
return {
|
||||
"message": f"Started downloading {len(task_ids)} episodes",
|
||||
"task_ids": task_ids,
|
||||
"total_episodes": len(episodes),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/anime/seasons")
|
||||
async def get_anime_seasons(url: str):
|
||||
"""Get list of seasons for an anime"""
|
||||
downloader = get_downloader(url)
|
||||
if hasattr(downloader, "get_seasons"):
|
||||
seasons = await downloader.get_seasons(url)
|
||||
return {"seasons": seasons or []}
|
||||
return {"seasons": [], "message": "Season info not available for this provider"}
|
||||
|
||||
|
||||
@router.get("/anime/mal/search")
|
||||
async def search_anime_mal_details(
|
||||
q: str = Query(..., description="Anime search query"),
|
||||
limit: int = Query(5, description="Number of results"),
|
||||
):
|
||||
"""Search for anime on MyAnimeList and get full details"""
|
||||
from app.recommendations import AnimeReleasesFetcher
|
||||
|
||||
fetcher = AnimeReleasesFetcher()
|
||||
try:
|
||||
search_results = await fetcher.search_anime(q, limit=limit)
|
||||
if not search_results:
|
||||
return {"anime": None, "message": "No anime found"}
|
||||
main_anime = search_results[0]
|
||||
anime_details = await fetcher.get_anime_details(main_anime["mal_id"])
|
||||
return {
|
||||
"anime": anime_details,
|
||||
"alternatives": search_results[1:],
|
||||
"total_results": len(search_results),
|
||||
}
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
|
||||
@router.post("/translate")
|
||||
async def translate_text(request: Request):
|
||||
"""Translate text from English to French using Google Translate"""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
text = body.get("text", "")
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Text is required")
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
url = "https://translate.googleapis.com/translate_a/single"
|
||||
params = {
|
||||
"client": "gtx",
|
||||
"sl": "en",
|
||||
"tl": "fr",
|
||||
"dt": "t",
|
||||
"q": text[:5000],
|
||||
}
|
||||
response = await client.get(url, params=params)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data and data[0]:
|
||||
translated = "".join([item[0] for item in data[0] if item[0]])
|
||||
return {"translatedText": translated, "status": "success"}
|
||||
raise HTTPException(status_code=500, detail="Translation failed")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}")
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
Authentication routes for Ohm Stream Downloader API.
|
||||
|
||||
Endpoints:
|
||||
- POST /api/auth/register - Register a new user
|
||||
- POST /api/auth/login - Login user and return JWT token
|
||||
- GET /api/auth/me - Get current user information
|
||||
- POST /api/auth/logout - Logout user (client-side)
|
||||
- POST /api/auth/refresh - Refresh access token
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from app.auth import (
|
||||
create_access_token,
|
||||
user_manager,
|
||||
verify_token,
|
||||
)
|
||||
from app.models.auth import User, UserCreate, UserLogin
|
||||
|
||||
security = HTTPBearer()
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
async def get_current_user_from_token(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> User:
|
||||
"""Dependency to get current user from JWT token"""
|
||||
token = credentials.credentials
|
||||
username = verify_token(token)
|
||||
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user = user_manager.get_user(username)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return User(
|
||||
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")
|
||||
async def register(user_data: UserCreate):
|
||||
"""Register a new user"""
|
||||
try:
|
||||
existing_user = user_manager.get_user(user_data.username)
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered",
|
||||
)
|
||||
|
||||
user = user_manager.create_user(
|
||||
username=user_data.username,
|
||||
password=user_data.password,
|
||||
email=user_data.email,
|
||||
full_name=user_data.full_name,
|
||||
)
|
||||
|
||||
user_response = 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,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "User registered successfully",
|
||||
"user": user_response,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error registering user: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to register user",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(form_data: UserLogin):
|
||||
"""Login user and return JWT token"""
|
||||
user = user_manager.authenticate_user(form_data.username, form_data.password)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
||||
)
|
||||
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username}, expires_delta=timedelta(days=7)
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: User = Depends(get_current_user_from_token)):
|
||||
"""Get current user information"""
|
||||
return {
|
||||
"user": {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"full_name": current_user.full_name,
|
||||
"is_active": current_user.is_active,
|
||||
"created_at": current_user.created_at,
|
||||
"last_login": current_user.last_login,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Logout user (client-side only)"""
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Logout successful. Please remove the token from client storage.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(refresh_request: dict):
|
||||
"""Refresh access token using a valid refresh token."""
|
||||
from app.auth import (
|
||||
verify_refresh_token,
|
||||
create_access_refresh_tokens,
|
||||
revoke_refresh_token,
|
||||
user_manager as um,
|
||||
)
|
||||
|
||||
refresh_token_value = refresh_request.get("refresh_token")
|
||||
if not refresh_token_value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Refresh token is required"
|
||||
)
|
||||
|
||||
username = verify_refresh_token(refresh_token_value)
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
user = um.get_user(username)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
||||
)
|
||||
|
||||
revoke_refresh_token(refresh_token_value)
|
||||
|
||||
access_token, new_refresh_token = create_access_refresh_tokens(
|
||||
data={"sub": username}
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": new_refresh_token,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Download management routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
|
||||
from app.download_manager import DownloadManager
|
||||
from app.models import DownloadRequest, DownloadStatus
|
||||
from app.models.auth import User
|
||||
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
def get_download_manager() -> DownloadManager:
|
||||
from main import download_manager
|
||||
return download_manager
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_downloads(
|
||||
request: Request,
|
||||
html: bool = Query(False),
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get list of all download tasks. Returns HTML for HTMX requests."""
|
||||
is_htmx = request.headers.get("HX-Request") == "true" or request.headers.get("HX-Request")
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
tasks = download_manager.get_all_tasks()
|
||||
|
||||
if html or is_htmx:
|
||||
print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.")
|
||||
return templates.TemplateResponse(
|
||||
"components/downloads_list.html",
|
||||
{"request": request, "tasks": tasks}
|
||||
)
|
||||
|
||||
print(f"[DOWNLOADS] API Request. Returning JSON.")
|
||||
return {"downloads": tasks}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_download(
|
||||
download_request: DownloadRequest,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user=Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Create a new download task"""
|
||||
return download_manager.create_task(download_request)
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
async def get_download_status(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get status of a specific download task"""
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/{task_id}/pause")
|
||||
async def pause_download(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user=Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Pause a download task"""
|
||||
if download_manager.pause_download(task_id):
|
||||
return {"status": "success", "message": "Download paused"}
|
||||
raise HTTPException(status_code=400, detail="Failed to pause download")
|
||||
|
||||
|
||||
@router.post("/{task_id}/resume")
|
||||
async def resume_download(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user=Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Resume a paused download task"""
|
||||
if download_manager.resume_download(task_id):
|
||||
return {"status": "success", "message": "Download resumed"}
|
||||
raise HTTPException(status_code=400, detail="Failed to resume download")
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
async def cancel_download(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user=Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Cancel and delete a download task"""
|
||||
if hasattr(download_manager, "cancel_download"):
|
||||
if download_manager.cancel_download(task_id):
|
||||
return {"status": "success", "message": "Download cancelled"}
|
||||
|
||||
if task_id in download_manager.tasks:
|
||||
del download_manager.tasks[task_id]
|
||||
return {"status": "success", "message": "Download removed"}
|
||||
|
||||
raise HTTPException(status_code=400, detail="Failed to cancel download")
|
||||
|
||||
|
||||
@router.get("/video/{task_id}")
|
||||
async def stream_video(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Stream a completed download as video"""
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if task.status != DownloadStatus.COMPLETED or not task.file_path:
|
||||
raise HTTPException(status_code=400, detail="Download not completed")
|
||||
file_path = Path(task.file_path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
media_types = {
|
||||
".mp4": "video/mp4", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo",
|
||||
".webm": "video/webm", ".flv": "video/x-flv",
|
||||
}
|
||||
media_type = media_types.get(file_path.suffix.lower(), "video/mp4")
|
||||
return FileResponse(str(file_path), media_type=media_type)
|
||||
|
||||
|
||||
@router.post("/{task_id}/retry")
|
||||
async def retry_download(
|
||||
task_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Retry a failed or cancelled download"""
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if task.status not in ("failed", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="Can only retry failed or cancelled downloads")
|
||||
task.status = DownloadStatus.PENDING
|
||||
task.progress = 0.0
|
||||
if hasattr(download_manager, "_process_download"):
|
||||
background_tasks.add_task(download_manager._process_download, task_id)
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"show-toast": {"message": "Telechargement relance", "type": "success"}}
|
||||
)
|
||||
return {"status": "retrying"}
|
||||
|
||||
|
||||
@router.post("/cancel-all")
|
||||
async def cancel_all_downloads(
|
||||
response: Response,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Cancel all active downloads"""
|
||||
count = 0
|
||||
for tid, task in list(download_manager.tasks.items()):
|
||||
if task.status in ("downloading", "pending"):
|
||||
download_manager.cancel_download(tid) if hasattr(download_manager, "cancel_download") else download_manager.tasks.pop(tid, None)
|
||||
count += 1
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"show-toast": {"message": f"{count} telechargement(s) annule(s)", "type": "info"}}
|
||||
)
|
||||
return {"status": "cancelled", "count": count}
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def cleanup_completed(
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user=Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Remove all completed tasks from the list"""
|
||||
if hasattr(download_manager, "cleanup_tasks"):
|
||||
count = download_manager.cleanup_tasks()
|
||||
return {"status": "success", "message": f"Cleaned up {count} tasks"}
|
||||
|
||||
to_delete = [tid for tid, t in download_manager.tasks.items() if t.status == "completed"]
|
||||
for tid in to_delete:
|
||||
del download_manager.tasks[tid]
|
||||
return {"status": "success", "message": f"Cleaned up {len(to_delete)} tasks"}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Favorites management routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.favorites import get_favorites_manager
|
||||
from app.models.auth import User
|
||||
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/favorites", tags=["favorites"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_favorites(
|
||||
request: Request,
|
||||
sort_by: str = "created_at",
|
||||
order: str = "desc",
|
||||
filter_provider: Optional[str] = None,
|
||||
filter_genre: Optional[str] = None,
|
||||
html: bool = Query(False),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""List all favorite anime with optional sorting and filtering"""
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
favorites = await fav_manager.list_favorites(
|
||||
user_id=current_user.id,
|
||||
sort_by=sort_by,
|
||||
order=order,
|
||||
filter_provider=filter_provider,
|
||||
filter_genre=filter_genre,
|
||||
)
|
||||
return {
|
||||
"favorites": favorites,
|
||||
"total": len(favorites),
|
||||
"filters": {
|
||||
"sort_by": sort_by,
|
||||
"order": order,
|
||||
"provider": filter_provider,
|
||||
"genre": filter_genre,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def add_favorite(
|
||||
request: Request,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Add an anime to favorites"""
|
||||
data = await request.json()
|
||||
|
||||
required_fields = ["anime_id", "title", "url", "provider"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Missing required field: {field}"
|
||||
)
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
favorite = await fav_manager.add_favorite(
|
||||
user_id=current_user.id,
|
||||
anime_id=data["anime_id"],
|
||||
title=data["title"],
|
||||
url=data["url"],
|
||||
provider=data["provider"],
|
||||
metadata=data.get("metadata"),
|
||||
poster_url=data.get("poster_url"),
|
||||
)
|
||||
|
||||
response.headers["HX-Trigger"] = '{"show-toast": {"message": "' + favorite['title'] + ' ajouté aux favoris", "type": "success"}}'
|
||||
return {"status": "added", "favorite": favorite}
|
||||
|
||||
|
||||
@router.delete("/{anime_id}")
|
||||
async def remove_favorite(
|
||||
anime_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Remove an anime from favorites"""
|
||||
fav_manager = get_favorites_manager()
|
||||
removed = await fav_manager.remove_favorite(anime_id, user_id=current_user.id)
|
||||
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
||||
|
||||
response.headers["HX-Trigger"] = '{"show-toast": {"message": "Favori supprimé", "type": "info"}}'
|
||||
return {"status": "removed", "anime_id": anime_id}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_favorites_stats(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get statistics about favorites"""
|
||||
fav_manager = get_favorites_manager()
|
||||
stats = await fav_manager.get_stats(user_id=current_user.id)
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/{anime_id}")
|
||||
async def get_favorite(
|
||||
anime_id: str,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get details of a specific favorite anime"""
|
||||
fav_manager = get_favorites_manager()
|
||||
favorite = await fav_manager.get_favorite(anime_id, user_id=current_user.id)
|
||||
|
||||
if not favorite:
|
||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
||||
|
||||
return {"favorite": favorite}
|
||||
|
||||
|
||||
@router.post("/toggle")
|
||||
async def toggle_favorite(
|
||||
request: Request,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Toggle an anime in favorites"""
|
||||
data = await request.json()
|
||||
|
||||
required_fields = ["anime_id", "title", "url", "provider"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Missing required field: {field}"
|
||||
)
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
result = await fav_manager.toggle_favorite(
|
||||
user_id=current_user.id,
|
||||
anime_id=data["anime_id"],
|
||||
title=data["title"],
|
||||
url=data["url"],
|
||||
provider=data["provider"],
|
||||
metadata=data.get("metadata"),
|
||||
poster_url=data.get("poster_url"),
|
||||
)
|
||||
|
||||
action = result.get("action", "unknown")
|
||||
message = f"'{data['title']}' {'ajouté aux' if action == 'added' else 'retiré des'} favoris"
|
||||
toast_type = "success" if action == "added" else "info"
|
||||
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{message}", "type": "{toast_type}"}}}}'
|
||||
return result
|
||||
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Video streaming routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from app.models import DownloadStatus
|
||||
|
||||
router = APIRouter(tags=["player"])
|
||||
|
||||
|
||||
def get_download_manager():
|
||||
from main import download_manager
|
||||
|
||||
return download_manager
|
||||
|
||||
|
||||
from app.downloaders import get_downloader
|
||||
|
||||
@router.get("/api/player/embed")
|
||||
async def get_player_embed(request: Request, url: str):
|
||||
"""
|
||||
Get an embedded video player for a given episode URL.
|
||||
This route extracts the direct video link and returns an HTML fragment.
|
||||
"""
|
||||
from main import templates
|
||||
|
||||
try:
|
||||
# 1. Get the downloader for the anime site (e.g. Anime-Sama)
|
||||
downloader = get_downloader(url)
|
||||
if not downloader:
|
||||
raise HTTPException(status_code=400, detail="No downloader found for this URL")
|
||||
|
||||
# 2. Get the video player URL (embed URL like VidMoly, DoodStream, etc.)
|
||||
video_url, _ = await downloader.get_download_link(url)
|
||||
|
||||
# 3. Get the direct video file link from the player
|
||||
player_handler = get_downloader(video_url)
|
||||
if not player_handler:
|
||||
# If no direct extractor, we might have to use an iframe
|
||||
return templates.TemplateResponse(
|
||||
"components/player_embed.html",
|
||||
{
|
||||
"request": request,
|
||||
"video_url": video_url,
|
||||
"is_iframe": True
|
||||
}
|
||||
)
|
||||
|
||||
direct_url, filename = await player_handler.get_download_link(video_url)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"components/player_embed.html",
|
||||
{
|
||||
"request": request,
|
||||
"video_url": direct_url,
|
||||
"filename": filename,
|
||||
"is_iframe": False
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Embed error: {e}", exc_info=True)
|
||||
return f"<div class='error-msg'>Erreur lors de l'extraction de la vidéo : {str(e)}</div>"
|
||||
|
||||
|
||||
@router.get("/video/{task_id}")
|
||||
async def stream_video(task_id: str, request: Request):
|
||||
"""Stream a video file with Range support for seeking"""
|
||||
download_manager = get_download_manager()
|
||||
task = download_manager.get_task(task_id)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
if task.status != DownloadStatus.COMPLETED:
|
||||
raise HTTPException(status_code=400, detail="Download not completed")
|
||||
|
||||
if not task.file_path or not os.path.exists(task.file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_path = Path(task.file_path)
|
||||
file_size = file_path.stat().st_size
|
||||
|
||||
range_header = request.headers.get("range")
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": "video/mp4",
|
||||
}
|
||||
|
||||
if range_header:
|
||||
try:
|
||||
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
|
||||
start = int(range_match.group(1))
|
||||
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
|
||||
|
||||
if start >= file_size or end >= file_size or start > end:
|
||||
headers["Content-Range"] = f"bytes */{file_size}"
|
||||
return Response(
|
||||
status_code=416,
|
||||
headers=headers,
|
||||
content="Requested Range Not Satisfiable",
|
||||
)
|
||||
|
||||
content_length = end - start + 1
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
headers["Content-Length"] = str(content_length)
|
||||
|
||||
def video_range_reader():
|
||||
with open(file_path, "rb") as f:
|
||||
f.seek(start)
|
||||
remaining = content_length
|
||||
while remaining > 0:
|
||||
chunk_size = min(1024 * 1024, remaining)
|
||||
data = f.read(chunk_size)
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
|
||||
return Response(
|
||||
content=video_range_reader(), status_code=206, headers=headers
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid Range header: {e}")
|
||||
else:
|
||||
|
||||
def video_reader():
|
||||
with open(file_path, "rb") as f:
|
||||
while True:
|
||||
data = f.read(1024 * 1024)
|
||||
if not data:
|
||||
break
|
||||
yield data
|
||||
|
||||
headers["Content-Length"] = str(file_size)
|
||||
return Response(content=video_reader(), headers=headers)
|
||||
|
||||
|
||||
@router.get("/stream/{filename}")
|
||||
async def stream_video_by_filename(filename: str, request: Request):
|
||||
"""Stream a video file by filename with Range support"""
|
||||
filename = os.path.basename(filename)
|
||||
file_path = Path("downloads") / filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
range_header = request.headers.get("range")
|
||||
|
||||
if range_header:
|
||||
try:
|
||||
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
|
||||
start = int(range_match.group(1))
|
||||
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
|
||||
|
||||
if start >= file_size or end >= file_size or start > end:
|
||||
return Response(
|
||||
status_code=416,
|
||||
headers={
|
||||
"Content-Range": f"bytes */{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
},
|
||||
content="Requested Range Not Satisfiable",
|
||||
)
|
||||
|
||||
content_length = end - start + 1
|
||||
|
||||
def video_range_reader():
|
||||
with open(file_path, "rb") as f:
|
||||
f.seek(start)
|
||||
remaining = content_length
|
||||
while remaining > 0:
|
||||
chunk_size = min(1024 * 1024, remaining)
|
||||
data = f.read(chunk_size)
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
|
||||
return StreamingResponse(
|
||||
video_range_reader(),
|
||||
status_code=206,
|
||||
headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Content-Length": str(content_length),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": "video/mp4",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid Range header: {e}")
|
||||
else:
|
||||
|
||||
def video_reader():
|
||||
with open(file_path, "rb") as f:
|
||||
while True:
|
||||
data = f.read(1024 * 1024)
|
||||
if not data:
|
||||
break
|
||||
yield data
|
||||
|
||||
return StreamingResponse(
|
||||
video_reader(),
|
||||
headers={
|
||||
"Content-Length": str(file_size),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": "video/mp4",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/player/{task_id}")
|
||||
async def video_player(request: Request, task_id: str):
|
||||
"""Video player page for watching downloaded anime"""
|
||||
from main import download_manager, templates
|
||||
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
if task.status != DownloadStatus.COMPLETED:
|
||||
raise HTTPException(status_code=400, detail="Download not completed")
|
||||
|
||||
if not task.file_path or not os.path.exists(task.file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_path = Path(task.file_path)
|
||||
file_size = file_path.stat().st_size
|
||||
estimated_duration_seconds = int(file_size / (1.5 * 1024 * 1024))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"player.html",
|
||||
{
|
||||
"request": request,
|
||||
"task_id": task_id,
|
||||
"filename": task.filename,
|
||||
"file_size": file_size,
|
||||
"estimated_duration": estimated_duration_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/watch/{filename}")
|
||||
async def video_player_by_filename(request: Request, filename: str):
|
||||
"""Video player page for watching downloaded anime by filename"""
|
||||
from main import templates
|
||||
from app.utils import is_safe_filename, sanitize_filename
|
||||
|
||||
filename = sanitize_filename(filename)
|
||||
|
||||
if not is_safe_filename(filename):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid filename. Path traversal attempts are not allowed.",
|
||||
)
|
||||
|
||||
file_path = Path("downloads") / filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
estimated_duration_seconds = int(file_size / (1.5 * 1024 * 1024))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"player.html",
|
||||
{
|
||||
"request": request,
|
||||
"task_id": filename,
|
||||
"filename": filename,
|
||||
"file_size": file_size,
|
||||
"estimated_duration": estimated_duration_seconds,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Recommendations and releases routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, Query, HTTPException, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.recommendation_engine import RecommendationEngine
|
||||
from app.models.auth import User
|
||||
from app.routers.router_auth import get_optional_user, get_current_user_from_token
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["recommendations"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Add custom filters to Jinja2
|
||||
def hash_filter(s):
|
||||
return hashlib.md5(s.encode()).hexdigest()[:10]
|
||||
|
||||
templates.env.filters["hash"] = hash_filter
|
||||
|
||||
|
||||
@router.get("/recommendations")
|
||||
async def get_recommendations(
|
||||
request: Request,
|
||||
limit: int = 15,
|
||||
html: bool = Query(False),
|
||||
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get personalized anime recommendations based on download history"""
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
|
||||
try:
|
||||
recommendations = await engine.get_personalized_recommendations(limit=limit)
|
||||
|
||||
# Filter by content_type if specified
|
||||
if content_type and content_type != "all":
|
||||
recommendations = [r for r in recommendations if r.get("content_type", r.get("type", "")) == content_type]
|
||||
|
||||
if html or is_htmx:
|
||||
return templates.TemplateResponse(
|
||||
"components/recommendations_list.html",
|
||||
{"request": request, "recommendations": recommendations}
|
||||
)
|
||||
|
||||
return {"recommendations": recommendations, "count": len(recommendations)}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Recommendations error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await engine.close()
|
||||
|
||||
|
||||
@router.get("/releases/latest")
|
||||
async def get_latest_releases(
|
||||
request: Request,
|
||||
limit: int = 20,
|
||||
html: bool = Query(False),
|
||||
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||
):
|
||||
"""Get latest anime releases"""
|
||||
from app.recommendations import get_latest_releases_with_info
|
||||
|
||||
try:
|
||||
releases = await get_latest_releases_with_info(limit=limit)
|
||||
|
||||
# Filter by content_type if specified
|
||||
if content_type and content_type != "all":
|
||||
releases = [r for r in releases if r.get("content_type", r.get("type", "")) == content_type]
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
"components/releases_list.html",
|
||||
{"request": request, "releases": releases}
|
||||
)
|
||||
|
||||
return {
|
||||
"releases": releases,
|
||||
"count": len(releases),
|
||||
"updated": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Latest releases error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/releases/seasonal")
|
||||
async def get_seasonal_anime(
|
||||
year: Optional[int] = None,
|
||||
season: Optional[str] = None,
|
||||
):
|
||||
"""Get current/previously seasonal anime"""
|
||||
from app.recommendations import AnimeReleasesFetcher
|
||||
|
||||
fetcher = AnimeReleasesFetcher()
|
||||
|
||||
try:
|
||||
anime = await fetcher.get_seasonal_anime(year, season)
|
||||
|
||||
return {
|
||||
"anime": anime,
|
||||
"count": len(anime),
|
||||
"year": year or datetime.now().year,
|
||||
"season": season or "current",
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
|
||||
@router.get("/releases/scheduled")
|
||||
async def get_scheduled_anime(day: Optional[str] = None):
|
||||
"""Get anime scheduled for a specific day"""
|
||||
from app.recommendations import AnimeReleasesFetcher
|
||||
|
||||
fetcher = AnimeReleasesFetcher()
|
||||
|
||||
try:
|
||||
anime = await fetcher.get_scheduled_anime(day)
|
||||
|
||||
return {"anime": anime, "count": len(anime), "day": day or "today"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
|
||||
@router.get("/releases/top")
|
||||
async def get_top_anime(
|
||||
type: str = "tv",
|
||||
limit: int = 15,
|
||||
):
|
||||
"""Get top rated anime"""
|
||||
from app.recommendations import AnimeReleasesFetcher
|
||||
|
||||
fetcher = AnimeReleasesFetcher()
|
||||
|
||||
try:
|
||||
anime = await fetcher.get_top_anime(type=type, limit=limit)
|
||||
|
||||
return {"anime": anime, "count": len(anime)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
|
||||
@router.get("/stats/downloads")
|
||||
async def get_download_statistics(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get download statistics and preferences"""
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
|
||||
try:
|
||||
stats = await engine.get_download_stats()
|
||||
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await engine.close()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Root routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app import providers
|
||||
|
||||
router = APIRouter(prefix="", tags=["root"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with API information"""
|
||||
return {
|
||||
"message": "Ohm Stream Downloader API",
|
||||
"status": "running",
|
||||
"version": "2.2",
|
||||
"endpoints": {
|
||||
"POST /api/download": "Start a new download",
|
||||
"GET /api/downloads": "List all downloads",
|
||||
"GET /api/download/{task_id}": "Get download status",
|
||||
"POST /api/download/{task_id}/pause": "Pause a download",
|
||||
"POST /api/download/{task_id}/resume": "Resume a download",
|
||||
"DELETE /api/download/{task_id}": "Cancel a download",
|
||||
"GET /api/providers": "List all supported providers",
|
||||
"GET /api/anime/search": "Search anime across all providers",
|
||||
"GET /api/anime/metadata": "Get detailed anime metadata",
|
||||
"GET /api/anime/episodes": "Get episode list for an anime",
|
||||
"POST /api/anime/download-season": "Download all episodes of a season",
|
||||
"GET /api/favorites": "List all favorite anime",
|
||||
"POST /api/favorites": "Add anime to favorites",
|
||||
"DELETE /api/favorites/{anime_id}": "Remove from favorites",
|
||||
"GET /api/favorites/{anime_id}": "Get favorite anime details",
|
||||
"GET /api/favorites/stats": "Get favorites statistics",
|
||||
"POST /api/favorites/toggle": "Toggle anime in favorites",
|
||||
"GET /web": "Web interface",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@router.get("/api/providers")
|
||||
async def list_providers():
|
||||
"""List all supported anime, series and file hosting providers"""
|
||||
return {
|
||||
"anime_providers": providers.get_anime_providers(),
|
||||
"series_providers": providers.get_series_providers(),
|
||||
"file_hosts": providers.get_file_hosts(),
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Application settings routes for Ohm Stream Downloader API"""
|
||||
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.database import get_session
|
||||
from app.models.auth import User, UserTable
|
||||
from app.models.settings import AppSettings, AppSettingsTable, AppSettingsUpdate
|
||||
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||
from app.providers import get_anime_providers, get_series_providers
|
||||
from app.providers_manager import providers_manager
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("", response_model=AppSettings)
|
||||
async def get_settings(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Get current application settings for the user"""
|
||||
statement = select(AppSettingsTable).where(
|
||||
AppSettingsTable.user_id == current_user.id
|
||||
)
|
||||
settings_obj = session.exec(statement).first()
|
||||
|
||||
if not settings_obj:
|
||||
# Create default settings if they don't exist
|
||||
settings_obj = AppSettingsTable(user_id=current_user.id)
|
||||
session.add(settings_obj)
|
||||
session.commit()
|
||||
session.refresh(settings_obj)
|
||||
|
||||
return AppSettings(
|
||||
default_lang=settings_obj.default_lang,
|
||||
theme=settings_obj.theme,
|
||||
disabled_providers=settings_obj.disabled_providers,
|
||||
recommendations_filter=getattr(settings_obj, 'recommendations_filter', 'all'),
|
||||
releases_filter=getattr(settings_obj, 'releases_filter', 'all'),
|
||||
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
|
||||
series_enabled=getattr(settings_obj, 'series_enabled', True),
|
||||
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("", response_model=AppSettings)
|
||||
async def update_settings(
|
||||
update_data: AppSettingsUpdate,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Update application settings for the user"""
|
||||
statement = select(AppSettingsTable).where(
|
||||
AppSettingsTable.user_id == current_user.id
|
||||
)
|
||||
settings_obj = session.exec(statement).first()
|
||||
|
||||
if not settings_obj:
|
||||
settings_obj = AppSettingsTable(user_id=current_user.id)
|
||||
session.add(settings_obj)
|
||||
|
||||
if update_data.default_lang is not None:
|
||||
settings_obj.default_lang = update_data.default_lang
|
||||
if update_data.theme is not None:
|
||||
settings_obj.theme = update_data.theme
|
||||
if update_data.disabled_providers is not None:
|
||||
settings_obj.disabled_providers = update_data.disabled_providers
|
||||
if update_data.recommendations_filter is not None:
|
||||
settings_obj.recommendations_filter = update_data.recommendations_filter
|
||||
if update_data.releases_filter is not None:
|
||||
settings_obj.releases_filter = update_data.releases_filter
|
||||
if update_data.anime_enabled is not None:
|
||||
# Prevent disabling both categories
|
||||
if not update_data.anime_enabled and not getattr(settings_obj, 'series_enabled', True):
|
||||
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
|
||||
settings_obj.anime_enabled = update_data.anime_enabled
|
||||
if update_data.series_enabled is not None:
|
||||
# Prevent disabling both categories
|
||||
if not update_data.series_enabled and not getattr(settings_obj, 'anime_enabled', True):
|
||||
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
|
||||
settings_obj.series_enabled = update_data.series_enabled
|
||||
if update_data.download_dir is not None:
|
||||
settings_obj.download_dir = update_data.download_dir
|
||||
|
||||
session.add(settings_obj)
|
||||
session.commit()
|
||||
session.refresh(settings_obj)
|
||||
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"show-toast": {"message": "Paramètres enregistrés", "type": "success"}}
|
||||
)
|
||||
|
||||
return settings_obj
|
||||
|
||||
|
||||
@router.get("/providers/availability")
|
||||
async def get_providers_availability(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Get list of providers with their availability and enabled status"""
|
||||
# Get user settings
|
||||
statement = select(AppSettingsTable).where(
|
||||
AppSettingsTable.user_id == current_user.id
|
||||
)
|
||||
settings_obj = session.exec(statement).first()
|
||||
disabled_providers = settings_obj.disabled_providers if settings_obj else []
|
||||
|
||||
# Get health status
|
||||
health_status = providers_manager.get_all_status()
|
||||
|
||||
# Combine anime and series providers
|
||||
all_providers = {**get_anime_providers(), **get_series_providers()}
|
||||
|
||||
result = []
|
||||
for pid, info in all_providers.items():
|
||||
status_info = health_status.get(pid, {"status": "unknown"})
|
||||
result.append(
|
||||
{
|
||||
"id": pid,
|
||||
"name": info["name"],
|
||||
"icon": info.get("icon", "🎬"),
|
||||
"status": status_info["status"],
|
||||
"enabled": pid not in disabled_providers,
|
||||
"error": status_info.get("error"),
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/providers/{provider_id}/toggle")
|
||||
async def toggle_provider(
|
||||
provider_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Toggle a provider's enabled/disabled status"""
|
||||
statement = select(AppSettingsTable).where(
|
||||
AppSettingsTable.user_id == current_user.id
|
||||
)
|
||||
settings_obj = session.exec(statement).first()
|
||||
|
||||
if not settings_obj:
|
||||
settings_obj = AppSettingsTable(user_id=current_user.id)
|
||||
session.add(settings_obj)
|
||||
|
||||
disabled = settings_obj.disabled_providers
|
||||
if provider_id in disabled:
|
||||
disabled.remove(provider_id)
|
||||
enabled = True
|
||||
else:
|
||||
disabled.append(provider_id)
|
||||
enabled = False
|
||||
|
||||
settings_obj.disabled_providers = disabled
|
||||
session.add(settings_obj)
|
||||
session.commit()
|
||||
|
||||
status_text = "activé" if enabled else "désactivé"
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": {
|
||||
"message": f"Fournisseur {provider_id} {status_text}",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {"id": provider_id, "enabled": enabled}
|
||||
|
||||
|
||||
@router.get("/ui")
|
||||
async def get_settings_ui(
|
||||
request: Request,
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
if current_user is None:
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
settings = await get_settings(current_user, session)
|
||||
providers = await get_providers_availability(current_user, session)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"components/settings_section.html",
|
||||
{"request": request, "settings": settings, "providers": providers},
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Sonarr integration routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from fastapi.requests import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models import DownloadRequest
|
||||
from app.models.auth import User
|
||||
from app.models.sonarr import SonarrConfig, SonarrDownloadRequest, SonarrMapping
|
||||
from app.routers.router_auth import get_current_user_from_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["sonarr"])
|
||||
|
||||
|
||||
def get_sonarr_handler():
|
||||
from app.sonarr_handler import get_sonarr_handler
|
||||
|
||||
return get_sonarr_handler()
|
||||
|
||||
|
||||
@router.post("/webhook/sonarr")
|
||||
async def sonarr_webhook(request: Request):
|
||||
"""Receive and process Sonarr webhook events"""
|
||||
from app.models.sonarr import SonarrWebhookPayload
|
||||
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
|
||||
body = await request.body()
|
||||
signature = request.headers.get("X-Sonarr-Event", "")
|
||||
if not sonarr_handler.verify_hmac(body, signature):
|
||||
logger.warning("Invalid HMAC signature for Sonarr webhook")
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
try:
|
||||
payload_data = await request.json()
|
||||
payload = SonarrWebhookPayload(**payload_data)
|
||||
result = await sonarr_handler.process_webhook(payload)
|
||||
return JSONResponse(content=result, status_code=200)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Sonarr webhook: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=422, detail=f"Invalid payload: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/webhook/test/sonarr")
|
||||
async def test_sonarr_webhook(request: Request):
|
||||
"""Test endpoint for Sonarr webhook configuration"""
|
||||
try:
|
||||
payload = await request.json()
|
||||
logger.info(
|
||||
f"Received test Sonarr webhook: {payload.get('eventType', 'unknown')}"
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Test webhook received successfully",
|
||||
"received_payload": payload,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error in test webhook: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.get("/sonarr/config")
|
||||
async def get_sonarr_config(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get Sonarr webhook configuration"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
return sonarr_handler.get_config()
|
||||
|
||||
|
||||
@router.put("/sonarr/config")
|
||||
async def update_sonarr_config(
|
||||
config: SonarrConfig,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Update Sonarr webhook configuration"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
updated_config = sonarr_handler.update_config(config)
|
||||
return {"status": "success", "config": updated_config}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating Sonarr config: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/sonarr/mappings")
|
||||
async def get_sonarr_mappings(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get all Sonarr to anime mappings"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
return sonarr_handler.get_mappings()
|
||||
|
||||
|
||||
@router.get("/sonarr/mappings/{series_id}")
|
||||
async def get_sonarr_mapping(
|
||||
series_id: int,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get specific mapping by Sonarr series ID"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
mapping = sonarr_handler.get_mapping(series_id)
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="Mapping not found")
|
||||
return mapping
|
||||
|
||||
|
||||
@router.post("/sonarr/mappings")
|
||||
async def create_sonarr_mapping(
|
||||
mapping: SonarrMapping,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Create or update a Sonarr to anime mapping"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
mapping = sonarr_handler.add_mapping(mapping)
|
||||
return {"status": "success", "mapping": mapping}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating mapping: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/sonarr/mappings/{series_id}")
|
||||
async def delete_sonarr_mapping(
|
||||
series_id: int,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Delete a Sonarr mapping"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
success = sonarr_handler.delete_mapping(series_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Mapping not found")
|
||||
return {"status": "success", "message": f"Mapping for series {series_id} deleted"}
|
||||
|
||||
|
||||
@router.get("/sonarr/search")
|
||||
async def search_anime_for_sonarr(
|
||||
q: str = Query(..., description="Series title to search"),
|
||||
provider: str = Query("anime-sama", description="Anime provider to search"),
|
||||
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Search for anime on providers to create Sonarr mappings"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
results = await sonarr_handler.search_anime_by_title(q, provider, lang)
|
||||
return {
|
||||
"status": "success",
|
||||
"query": q,
|
||||
"provider": provider,
|
||||
"lang": lang,
|
||||
"results": results,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching anime: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/sonarr/episodes")
|
||||
async def get_anime_episodes(
|
||||
url: str = Query(..., description="Anime URL from provider"),
|
||||
provider: str = Query("anime-sama", description="Anime provider"),
|
||||
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get episode list for anime"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
episodes = await sonarr_handler.get_episodes_for_anime(url, provider, lang)
|
||||
return {
|
||||
"status": "success",
|
||||
"url": url,
|
||||
"provider": provider,
|
||||
"lang": lang,
|
||||
"episodes": episodes,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting episodes: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/sonarr/suggest")
|
||||
async def suggest_anime_mapping(
|
||||
sonarr_title: str = Query(..., description="Sonarr series title"),
|
||||
provider: str = Query("anime-sama", description="Anime provider"),
|
||||
lang: str = Query("vostfr", description="Language"),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Suggest possible anime mappings based on Sonarr series title"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
suggestions = await sonarr_handler.suggest_mapping(sonarr_title, provider, lang)
|
||||
return {
|
||||
"status": "success",
|
||||
"sonarr_title": sonarr_title,
|
||||
"provider": provider,
|
||||
"lang": lang,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting suggestions: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/sonarr/download")
|
||||
async def trigger_sonarr_download(
|
||||
request: SonarrDownloadRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Manually trigger a download based on Sonarr information"""
|
||||
from main import download_manager
|
||||
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
|
||||
mapping = sonarr_handler.get_mapping(request.sonarr_series_id)
|
||||
if not mapping:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No mapping found for series {request.sonarr_series_id}. Create a mapping first.",
|
||||
)
|
||||
|
||||
try:
|
||||
episodes = await sonarr_handler.get_episodes_for_anime(
|
||||
mapping.anime_url,
|
||||
request.provider or mapping.anime_provider,
|
||||
request.lang or mapping.lang,
|
||||
)
|
||||
|
||||
target_episode = None
|
||||
for ep in episodes:
|
||||
ep_num = ep.get("episode", 0)
|
||||
season_num = ep.get("season", 1)
|
||||
if ep_num == request.episode_number and season_num == request.season_number:
|
||||
target_episode = ep
|
||||
break
|
||||
|
||||
if not target_episode:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Episode S{request.season_number}E{request.episode_number} not found",
|
||||
)
|
||||
|
||||
episode_url = target_episode.get("url")
|
||||
if not episode_url:
|
||||
raise HTTPException(status_code=400, detail="Episode URL not found")
|
||||
|
||||
download_request = DownloadRequest(
|
||||
url=episode_url,
|
||||
filename=f"{mapping.anime_title} - S{request.season_number}E{request.episode_number}.mp4",
|
||||
)
|
||||
|
||||
task = download_manager.create_task(download_request)
|
||||
background_tasks.add_task(download_manager.start_download, task.id)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"task_id": task.id,
|
||||
"message": f"Download started for {mapping.anime_title} S{request.season_number}E{request.episode_number}",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error triggering download: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Static pages routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
router = APIRouter(tags=["static"])
|
||||
|
||||
|
||||
def get_templates():
|
||||
from main import templates
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
@router.get("/web")
|
||||
async def web_interface(request: Request):
|
||||
"""Web interface"""
|
||||
templates = get_templates()
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login_page(request: Request):
|
||||
"""Login/Register page"""
|
||||
templates = get_templates()
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/watchlist")
|
||||
async def watchlist_redirect():
|
||||
"""Redirect /watchlist to web interface with watchlist hash"""
|
||||
return RedirectResponse("/web#watchlist")
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Watchlist management routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Response,
|
||||
Request,
|
||||
Query,
|
||||
)
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.download_manager import DownloadManager
|
||||
from app.downloaders import get_downloader
|
||||
from app.models import DownloadRequest
|
||||
from app.models.auth import User
|
||||
from app.models.watchlist import (
|
||||
WatchlistItem,
|
||||
WatchlistItemCreate,
|
||||
WatchlistItemUpdate,
|
||||
WatchlistSettings,
|
||||
WatchlistStatus,
|
||||
)
|
||||
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
def get_download_manager() -> DownloadManager:
|
||||
from main import download_manager
|
||||
|
||||
return download_manager
|
||||
|
||||
|
||||
@router.post("", response_model=WatchlistItem)
|
||||
async def add_to_watchlist(
|
||||
item_data: WatchlistItemCreate,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Add an anime to the watchlist"""
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
try:
|
||||
existing = watchlist_manager.get_by_anime_url(
|
||||
item_data.anime_url, current_user.id
|
||||
)
|
||||
item = watchlist_manager.add(current_user.id, item_data)
|
||||
|
||||
msg = (
|
||||
f"'{item.anime_title}' ajouté à la watchlist"
|
||||
if not existing
|
||||
else f"'{item.anime_title}' est déjà dans la watchlist"
|
||||
)
|
||||
toast_type = "success" if not existing else "info"
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"show-toast": {"message": msg, "type": toast_type}}
|
||||
)
|
||||
|
||||
return item
|
||||
except Exception as e:
|
||||
from logging import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.error(f"Error adding to watchlist: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_watchlist(
|
||||
request: Request,
|
||||
status: Optional[WatchlistStatus] = None,
|
||||
html: bool = Query(False),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
items = watchlist_manager.get_all(user_id=current_user.id, status=status)
|
||||
|
||||
if html or is_htmx:
|
||||
return templates.TemplateResponse(
|
||||
"components/watchlist_items_list.html", {"request": request, "items": items}
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/settings", response_model=WatchlistSettings)
|
||||
async def get_watchlist_settings(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get global watchlist settings"""
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
return watchlist_manager.get_settings()
|
||||
|
||||
|
||||
@router.put("/settings", response_model=WatchlistSettings)
|
||||
async def update_watchlist_settings(
|
||||
settings: WatchlistSettings,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Update global watchlist settings"""
|
||||
from app.auto_download_scheduler import auto_download_scheduler
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
try:
|
||||
updated_settings = watchlist_manager.update_settings(settings)
|
||||
if auto_download_scheduler.is_running():
|
||||
auto_download_scheduler.update_interval(
|
||||
updated_settings.check_interval_hours
|
||||
)
|
||||
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": {
|
||||
"message": "Paramètres de la watchlist mis à jour",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
return updated_settings
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=WatchlistItem)
|
||||
async def get_watchlist_item(
|
||||
item_id: str,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get a specific watchlist item"""
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item or item.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
return item
|
||||
|
||||
|
||||
@router.put("/{item_id}", response_model=WatchlistItem)
|
||||
async def update_watchlist_item(
|
||||
item_id: str,
|
||||
update_data: WatchlistItemUpdate,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Update a watchlist item"""
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item or item.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
)
|
||||
return updated_item
|
||||
|
||||
|
||||
@router.delete("/{item_id}")
|
||||
async def delete_from_watchlist(
|
||||
item_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Remove an anime from the watchlist"""
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item or item.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
title = item.anime_title
|
||||
if watchlist_manager.delete(item_id):
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": {
|
||||
"message": f"'{title}' supprimé de la watchlist",
|
||||
"type": "info",
|
||||
}
|
||||
}
|
||||
)
|
||||
# HTMX will handle removing the element if target is specified in the frontend
|
||||
return Response(status_code=204)
|
||||
|
||||
raise HTTPException(status_code=500, detail="Failed to delete item")
|
||||
|
||||
|
||||
@router.post("/check", response_model=List)
|
||||
async def check_watchlist_now(
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Trigger an immediate check for new episodes"""
|
||||
from app.auto_download_scheduler import auto_download_scheduler
|
||||
|
||||
background_tasks.add_task(auto_download_scheduler.trigger_check_now)
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": {
|
||||
"message": "Vérification de la watchlist lancée en arrière-plan",
|
||||
"type": "info",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {"status": "success", "message": "Check triggered"}
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_watchlist_stats(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get watchlist statistics for the user"""
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
return watchlist_manager.get_stats(current_user.id)
|
||||
+146
-115
@@ -1,91 +1,173 @@
|
||||
"""Sonarr webhook handler and integration logic"""
|
||||
"""Sonarr webhook handler and integration logic using SQLModel"""
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, Dict, List, Tuple, Any
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from app.database import engine
|
||||
from app.models.sonarr import (
|
||||
SonarrWebhookPayload,
|
||||
SonarrEventType,
|
||||
SonarrMapping,
|
||||
SonarrMappingTable,
|
||||
SonarrConfig,
|
||||
SonarrConfigTable,
|
||||
SonarrDownloadRequest
|
||||
)
|
||||
from app.models import DownloadRequest
|
||||
from app.downloaders import get_downloader, AnimeSamaDownloader, NekoSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader
|
||||
from app.downloaders import get_downloader, AnimeSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SonarrHandler:
|
||||
"""Handles Sonarr webhooks and manages series mappings"""
|
||||
"""Handles Sonarr webhooks and manages series mappings using SQL database"""
|
||||
|
||||
def __init__(self, config_path: str = "config/sonarr.json", mappings_path: str = "config/sonarr_mappings.json"):
|
||||
self.config_path = Path(config_path)
|
||||
self.mappings_path = Path(mappings_path)
|
||||
self.config = self._load_config()
|
||||
self.mappings = self._load_mappings()
|
||||
def __init__(self, config_path: str = None, mappings_path: str = None):
|
||||
self.download_manager = None
|
||||
|
||||
# Create config directories if they don't exist
|
||||
self.config_path.parent.mkdir(exist_ok=True)
|
||||
self.mappings_path.parent.mkdir(exist_ok=True)
|
||||
self._ensure_default_config()
|
||||
|
||||
def set_download_manager(self, download_manager):
|
||||
self.download_manager = download_manager
|
||||
|
||||
def _load_config(self) -> SonarrConfig:
|
||||
"""Load Sonarr configuration from file"""
|
||||
if self.config_path.exists():
|
||||
try:
|
||||
with open(self.config_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
return SonarrConfig(**data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load Sonarr config: {e}")
|
||||
return SonarrConfig()
|
||||
def _ensure_default_config(self):
|
||||
"""Ensure a default config exists in the database"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrConfigTable)
|
||||
if not session.exec(statement).first():
|
||||
session.add(SonarrConfigTable())
|
||||
session.commit()
|
||||
|
||||
def _save_config(self):
|
||||
try:
|
||||
temp_file = f"{self.config_path}.tmp"
|
||||
with open(temp_file, 'w') as f:
|
||||
json.dump(self.config.model_dump(mode='json'), f, indent=2)
|
||||
os.replace(temp_file, self.config_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save Sonarr config: {e}")
|
||||
raise
|
||||
def get_config(self) -> SonarrConfig:
|
||||
"""Get current configuration"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrConfigTable)
|
||||
db_config = session.exec(statement).first()
|
||||
if db_config:
|
||||
return SonarrConfig(
|
||||
webhook_enabled=db_config.webhook_enabled,
|
||||
webhook_secret=db_config.webhook_secret,
|
||||
auto_download_enabled=db_config.auto_download_enabled,
|
||||
default_language=db_config.default_language,
|
||||
default_quality=db_config.default_quality,
|
||||
default_provider=db_config.default_provider,
|
||||
verify_hmac=db_config.verify_hmac,
|
||||
log_webhooks=db_config.log_webhooks
|
||||
)
|
||||
return SonarrConfig()
|
||||
|
||||
def _load_mappings(self) -> List[SonarrMapping]:
|
||||
"""Load Sonarr to anime mappings from file"""
|
||||
if self.mappings_path.exists():
|
||||
try:
|
||||
with open(self.mappings_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
return [SonarrMapping(**item) for item in data]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load Sonarr mappings: {e}")
|
||||
return []
|
||||
def update_config(self, config: SonarrConfig) -> SonarrConfig:
|
||||
"""Update configuration"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrConfigTable)
|
||||
db_config = session.exec(statement).first()
|
||||
|
||||
if not db_config:
|
||||
db_config = SonarrConfigTable()
|
||||
|
||||
db_config.webhook_enabled = config.webhook_enabled
|
||||
db_config.webhook_secret = config.webhook_secret
|
||||
db_config.auto_download_enabled = config.auto_download_enabled
|
||||
db_config.default_language = config.default_language
|
||||
db_config.default_quality = config.default_quality
|
||||
db_config.default_provider = config.default_provider
|
||||
db_config.verify_hmac = config.verify_hmac
|
||||
db_config.log_webhooks = config.log_webhooks
|
||||
|
||||
session.add(db_config)
|
||||
session.commit()
|
||||
|
||||
logger.info("Sonarr configuration updated in database")
|
||||
return config
|
||||
|
||||
def _save_mappings(self):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.mappings_path), exist_ok=True)
|
||||
temp_file = f"{self.mappings_path}.tmp"
|
||||
with open(temp_file, 'w') as f:
|
||||
mappings_data = [m.model_dump(mode='json') for m in self.mappings]
|
||||
json.dump(mappings_data, f, indent=2)
|
||||
os.replace(temp_file, self.mappings_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save mappings: {e}")
|
||||
raise
|
||||
def _to_pydantic(self, db_mapping: SonarrMappingTable) -> SonarrMapping:
|
||||
return SonarrMapping(
|
||||
sonarr_series_id=db_mapping.sonarr_series_id,
|
||||
sonarr_title=db_mapping.sonarr_title,
|
||||
anime_provider=db_mapping.anime_provider,
|
||||
anime_url=db_mapping.anime_url,
|
||||
anime_title=db_mapping.anime_title,
|
||||
lang=db_mapping.lang,
|
||||
quality_preference=db_mapping.quality_preference,
|
||||
auto_download=db_mapping.auto_download,
|
||||
created_at=db_mapping.created_at,
|
||||
updated_at=db_mapping.updated_at
|
||||
)
|
||||
|
||||
def get_mappings(self) -> List[SonarrMapping]:
|
||||
"""Get all mappings"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrMappingTable)
|
||||
db_mappings = session.exec(statement).all()
|
||||
return [self._to_pydantic(m) for m in db_mappings]
|
||||
|
||||
def get_mapping(self, sonarr_series_id: int) -> Optional[SonarrMapping]:
|
||||
"""Get mapping for specific series"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == sonarr_series_id)
|
||||
db_mapping = session.exec(statement).first()
|
||||
if db_mapping:
|
||||
return self._to_pydantic(db_mapping)
|
||||
return None
|
||||
|
||||
def add_mapping(self, mapping: SonarrMapping) -> SonarrMapping:
|
||||
"""Add or update a mapping"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == mapping.sonarr_series_id)
|
||||
db_mapping = session.exec(statement).first()
|
||||
|
||||
if db_mapping:
|
||||
# Update existing
|
||||
db_mapping.sonarr_title = mapping.sonarr_title
|
||||
db_mapping.anime_provider = mapping.anime_provider
|
||||
db_mapping.anime_url = mapping.anime_url
|
||||
db_mapping.anime_title = mapping.anime_title
|
||||
db_mapping.lang = mapping.lang
|
||||
db_mapping.quality_preference = mapping.quality_preference
|
||||
db_mapping.auto_download = mapping.auto_download
|
||||
db_mapping.updated_at = datetime.now()
|
||||
logger.info(f"Updated mapping for series {mapping.sonarr_title}")
|
||||
else:
|
||||
# Create new
|
||||
db_mapping = SonarrMappingTable(
|
||||
user_id="default",
|
||||
sonarr_series_id=mapping.sonarr_series_id,
|
||||
sonarr_title=mapping.sonarr_title,
|
||||
anime_provider=mapping.anime_provider,
|
||||
anime_url=mapping.anime_url,
|
||||
anime_title=mapping.anime_title,
|
||||
lang=mapping.lang,
|
||||
quality_preference=mapping.quality_preference,
|
||||
auto_download=mapping.auto_download,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
logger.info(f"Added mapping for series {mapping.sonarr_title}")
|
||||
|
||||
session.add(db_mapping)
|
||||
session.commit()
|
||||
session.refresh(db_mapping)
|
||||
return self._to_pydantic(db_mapping)
|
||||
|
||||
def delete_mapping(self, sonarr_series_id: int) -> bool:
|
||||
"""Delete a mapping"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == sonarr_series_id)
|
||||
db_mapping = session.exec(statement).first()
|
||||
if db_mapping:
|
||||
session.delete(db_mapping)
|
||||
session.commit()
|
||||
logger.info(f"Deleted mapping for series ID {sonarr_series_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def verify_hmac(self, payload: bytes, signature: str) -> bool:
|
||||
"""Verify HMAC SHA256 signature"""
|
||||
if not self.config.verify_hmac or not self.config.webhook_secret:
|
||||
config = self.get_config()
|
||||
if not config.verify_hmac or not config.webhook_secret:
|
||||
return True
|
||||
|
||||
try:
|
||||
@@ -94,7 +176,7 @@ class SonarrHandler:
|
||||
signature = signature[7:]
|
||||
|
||||
computed_hmac = hmac.new(
|
||||
self.config.webhook_secret.encode(),
|
||||
config.webhook_secret.encode(),
|
||||
payload,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
@@ -104,57 +186,6 @@ class SonarrHandler:
|
||||
logger.error(f"HMAC verification failed: {e}")
|
||||
return False
|
||||
|
||||
def get_config(self) -> SonarrConfig:
|
||||
"""Get current configuration"""
|
||||
return self.config
|
||||
|
||||
def update_config(self, config: SonarrConfig) -> SonarrConfig:
|
||||
"""Update configuration"""
|
||||
self.config = config
|
||||
self._save_config()
|
||||
logger.info("Sonarr configuration updated")
|
||||
return self.config
|
||||
|
||||
def get_mappings(self) -> List[SonarrMapping]:
|
||||
"""Get all mappings"""
|
||||
return self.mappings
|
||||
|
||||
def get_mapping(self, sonarr_series_id: int) -> Optional[SonarrMapping]:
|
||||
"""Get mapping for specific series"""
|
||||
for mapping in self.mappings:
|
||||
if mapping.sonarr_series_id == sonarr_series_id:
|
||||
return mapping
|
||||
return None
|
||||
|
||||
def add_mapping(self, mapping: SonarrMapping) -> SonarrMapping:
|
||||
"""Add or update a mapping"""
|
||||
# Check if mapping already exists
|
||||
for i, existing in enumerate(self.mappings):
|
||||
if existing.sonarr_series_id == mapping.sonarr_series_id:
|
||||
mapping.updated_at = datetime.now()
|
||||
self.mappings[i] = mapping
|
||||
self._save_mappings()
|
||||
logger.info(f"Updated mapping for series {mapping.sonarr_title}")
|
||||
return mapping
|
||||
|
||||
# Add new mapping
|
||||
mapping.created_at = datetime.now()
|
||||
mapping.updated_at = datetime.now()
|
||||
self.mappings.append(mapping)
|
||||
self._save_mappings()
|
||||
logger.info(f"Added mapping for series {mapping.sonarr_title}")
|
||||
return mapping
|
||||
|
||||
def delete_mapping(self, sonarr_series_id: int) -> bool:
|
||||
"""Delete a mapping"""
|
||||
for i, mapping in enumerate(self.mappings):
|
||||
if mapping.sonarr_series_id == sonarr_series_id:
|
||||
del self.mappings[i]
|
||||
self._save_mappings()
|
||||
logger.info(f"Deleted mapping for series ID {sonarr_series_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def search_anime_by_title(self, title: str, provider: str = "anime-sama", lang: str = "vostfr") -> List[Dict]:
|
||||
"""Search for anime by title using specified provider"""
|
||||
try:
|
||||
@@ -174,7 +205,6 @@ class SonarrHandler:
|
||||
"""Get downloader instance for provider"""
|
||||
providers = {
|
||||
"anime-sama": AnimeSamaDownloader(),
|
||||
"neko-sama": NekoSamaDownloader(),
|
||||
"anime-ultime": AnimeUltimeDownloader(),
|
||||
"vostfree": VostfreeDownloader()
|
||||
}
|
||||
@@ -197,15 +227,16 @@ class SonarrHandler:
|
||||
|
||||
async def process_webhook(self, payload: SonarrWebhookPayload) -> Dict[str, Any]:
|
||||
"""Process Sonarr webhook payload"""
|
||||
if not self.config.webhook_enabled:
|
||||
config = self.get_config()
|
||||
if not config.webhook_enabled:
|
||||
return {"status": "ignored", "reason": "Webhook not enabled"}
|
||||
|
||||
if self.config.log_webhooks:
|
||||
if config.log_webhooks:
|
||||
logger.info(f"Received Sonarr webhook: {payload.eventType.value}")
|
||||
|
||||
# Handle different event types
|
||||
if payload.eventType == SonarrEventType.GRAB:
|
||||
return await self._handle_grab(payload)
|
||||
return await self._handle_grab(payload, config)
|
||||
elif payload.eventType == SonarrEventType.DOWNLOAD:
|
||||
return await self._handle_download(payload)
|
||||
elif payload.eventType == SonarrEventType.RENAME:
|
||||
@@ -217,9 +248,9 @@ class SonarrHandler:
|
||||
else:
|
||||
return {"status": "ignored", "reason": f"Unhandled event type: {payload.eventType}"}
|
||||
|
||||
async def _handle_grab(self, payload: SonarrWebhookPayload) -> Dict:
|
||||
async def _handle_grab(self, payload: SonarrWebhookPayload, config: SonarrConfig) -> Dict:
|
||||
"""Handle Grab event (when Sonarr downloads a release)"""
|
||||
if not self.config.auto_download_enabled:
|
||||
if not config.auto_download_enabled:
|
||||
return {"status": "ignored", "reason": "Auto-download disabled"}
|
||||
|
||||
if not payload.series or not payload.episodes:
|
||||
|
||||
+102
@@ -2,12 +2,114 @@
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DomainManager:
|
||||
"""
|
||||
Manages active domains for providers that frequently change TLDs.
|
||||
Handles verification, caching, and persistence of working domains.
|
||||
"""
|
||||
|
||||
_cache_file = Path("config/domain_cache.json")
|
||||
_cache = {}
|
||||
_cache_expiry = timedelta(hours=12)
|
||||
|
||||
@classmethod
|
||||
def _load_cache(cls):
|
||||
"""Load domain cache from disk"""
|
||||
if not cls._cache and cls._cache_file.exists():
|
||||
try:
|
||||
with open(cls._cache_file, 'r') as f:
|
||||
cls._cache = json.load(f)
|
||||
logger.debug(f"Loaded domain cache: {cls._cache}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading domain cache: {e}")
|
||||
cls._cache = {}
|
||||
|
||||
@classmethod
|
||||
def _save_cache(cls):
|
||||
"""Save domain cache to disk"""
|
||||
try:
|
||||
cls._cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(cls._cache_file, 'w') as f:
|
||||
json.dump(cls._cache, f, indent=4)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving domain cache: {e}")
|
||||
|
||||
@classmethod
|
||||
async def get_active_domain(cls, provider_id: str, default_domain: str, test_tlds: list[str], test_path: str = "/") -> str:
|
||||
"""
|
||||
Get the current active domain for a provider, testing TLDs if needed.
|
||||
|
||||
Args:
|
||||
provider_id: Unique identifier for the provider (e.g., 'zonetelechargement')
|
||||
default_domain: Domain to use if no others work (e.g., 'zone-telechargement.cam')
|
||||
test_tlds: List of TLDs to test (e.g., ['cam', 'net', 'org', 'blue'])
|
||||
test_path: Path to test on the domain (e.g., '/search')
|
||||
|
||||
Returns:
|
||||
The first working domain found, or the default.
|
||||
"""
|
||||
cls._load_cache()
|
||||
|
||||
# Check cache first
|
||||
cached = cls._cache.get(provider_id)
|
||||
if cached:
|
||||
last_check = datetime.fromisoformat(cached['last_check'])
|
||||
if datetime.now() - last_check < cls._cache_expiry:
|
||||
return cached['domain']
|
||||
|
||||
# Strip TLD from default domain to get base
|
||||
base_domain = default_domain.split('.')[0]
|
||||
if '-' in default_domain:
|
||||
# Handle cases like zone-telechargement
|
||||
base_domain = '.'.join(default_domain.split('.')[:-1])
|
||||
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0, follow_redirects=True) as client:
|
||||
# 1. Test cached domain first if it exists (even if expired)
|
||||
test_domains = []
|
||||
if cached:
|
||||
test_domains.append(cached['domain'])
|
||||
|
||||
# 2. Test provided TLDs
|
||||
for tld in test_tlds:
|
||||
domain = f"{base_domain}.{tld}"
|
||||
if domain not in test_domains:
|
||||
test_domains.append(domain)
|
||||
|
||||
# 3. Add default as last resort
|
||||
if default_domain not in test_domains:
|
||||
test_domains.append(default_domain)
|
||||
|
||||
for domain in test_domains:
|
||||
try:
|
||||
url = f"https://{domain}{test_path}"
|
||||
logger.debug(f"Testing domain for {provider_id}: {url}")
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Active domain found for {provider_id}: {domain}")
|
||||
cls._cache[provider_id] = {
|
||||
'domain': domain,
|
||||
'last_check': datetime.now().isoformat()
|
||||
}
|
||||
cls._save_cache()
|
||||
return domain
|
||||
except Exception as e:
|
||||
logger.debug(f"Domain test failed for {domain}: {e}")
|
||||
continue
|
||||
|
||||
logger.warning(f"Could not verify domain for {provider_id}, using default: {default_domain}")
|
||||
return default_domain
|
||||
|
||||
|
||||
def sanitize_filename(filename: str, max_length: int = 255) -> str:
|
||||
"""
|
||||
Safely sanitize filenames to prevent path traversal and invalid characters
|
||||
|
||||
+188
-186
@@ -1,4 +1,4 @@
|
||||
"""Watchlist management system for automatic episode tracking and downloading"""
|
||||
"""Watchlist management system for automatic episode tracking and downloading with SQLModel support"""
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
@@ -7,255 +7,257 @@ from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from app.database import engine
|
||||
from app.models.watchlist import (
|
||||
WatchlistItem,
|
||||
WatchlistItemTable,
|
||||
WatchlistItemCreate,
|
||||
WatchlistItemUpdate,
|
||||
WatchlistStatus,
|
||||
WatchlistSettings,
|
||||
WatchlistSettingsTable,
|
||||
NewEpisodeInfo,
|
||||
AutoDownloadResult
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Watchlist database file
|
||||
WATCHLIST_DB_FILE = "config/watchlist.json"
|
||||
WATCHLIST_SETTINGS_FILE = "config/watchlist_settings.json"
|
||||
|
||||
|
||||
class WatchlistManager:
|
||||
"""Manages user watchlist for automatic episode downloads"""
|
||||
"""Manages user watchlist for automatic episode downloads using SQL database"""
|
||||
|
||||
def __init__(self, db_file: str = WATCHLIST_DB_FILE):
|
||||
self.db_file = db_file
|
||||
self.settings_file = WATCHLIST_SETTINGS_FILE
|
||||
self.watchlist: Dict[str, WatchlistItem] = {}
|
||||
def __init__(self):
|
||||
self.settings: Optional[WatchlistSettings] = None
|
||||
self._load_watchlist()
|
||||
self._load_settings()
|
||||
|
||||
def _load_watchlist(self):
|
||||
"""Load watchlist from JSON file"""
|
||||
try:
|
||||
if os.path.exists(self.db_file):
|
||||
with open(self.db_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.watchlist = {
|
||||
item_id: WatchlistItem(**item_data)
|
||||
for item_id, item_data in data.items()
|
||||
}
|
||||
logger.info(f"Loaded {len(self.watchlist)} items from watchlist")
|
||||
else:
|
||||
self.watchlist = {}
|
||||
logger.info("Watchlist database not found, starting with empty watchlist")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading watchlist: {e}")
|
||||
self.watchlist = {}
|
||||
|
||||
def _save_watchlist(self):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.db_file), exist_ok=True)
|
||||
data = {
|
||||
item_id: item.model_dump(mode='json')
|
||||
for item_id, item in self.watchlist.items()
|
||||
}
|
||||
temp_file = f"{self.db_file}.tmp"
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
|
||||
os.replace(temp_file, self.db_file)
|
||||
logger.debug(f"Saved {len(self.watchlist)} items to watchlist")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving watchlist: {e}")
|
||||
|
||||
def _load_settings(self):
|
||||
"""Load watchlist settings from JSON file"""
|
||||
"""Load watchlist settings from database"""
|
||||
try:
|
||||
if os.path.exists(self.settings_file):
|
||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.settings = WatchlistSettings(**data)
|
||||
logger.info(f"Loaded watchlist settings")
|
||||
else:
|
||||
self.settings = WatchlistSettings()
|
||||
self._save_settings()
|
||||
logger.info("Settings file not found, using defaults")
|
||||
with Session(engine) as session:
|
||||
statement = select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == "default")
|
||||
db_settings = session.exec(statement).first()
|
||||
if db_settings:
|
||||
self.settings = WatchlistSettings(
|
||||
check_interval_hours=db_settings.check_interval_hours,
|
||||
auto_download_enabled=db_settings.auto_download_enabled,
|
||||
max_concurrent_auto_downloads=db_settings.max_concurrent_auto_downloads,
|
||||
notify_on_new_episodes=db_settings.notify_on_new_episodes,
|
||||
include_completed_anime=db_settings.include_completed_anime
|
||||
)
|
||||
logger.info(f"Loaded watchlist settings from database")
|
||||
else:
|
||||
self.settings = WatchlistSettings()
|
||||
self._save_settings()
|
||||
logger.info("Settings not found in database, created defaults")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading settings: {e}")
|
||||
logger.error(f"Error loading settings from database: {e}")
|
||||
self.settings = WatchlistSettings()
|
||||
|
||||
def _save_settings(self):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True)
|
||||
temp_file = f"{self.settings_file}.tmp"
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.settings.model_dump(mode='json'), f, indent=2, ensure_ascii=False)
|
||||
os.replace(temp_file, self.settings_file)
|
||||
logger.debug("Saved watchlist settings")
|
||||
with Session(engine) as session:
|
||||
statement = select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == "default")
|
||||
db_settings = session.exec(statement).first()
|
||||
|
||||
if db_settings:
|
||||
db_settings.check_interval_hours = self.settings.check_interval_hours
|
||||
db_settings.auto_download_enabled = self.settings.auto_download_enabled
|
||||
db_settings.max_concurrent_auto_downloads = self.settings.max_concurrent_auto_downloads
|
||||
db_settings.notify_on_new_episodes = self.settings.notify_on_new_episodes
|
||||
db_settings.include_completed_anime = self.settings.include_completed_anime
|
||||
else:
|
||||
db_settings = WatchlistSettingsTable(
|
||||
user_id="default",
|
||||
check_interval_hours=self.settings.check_interval_hours,
|
||||
auto_download_enabled=self.settings.auto_download_enabled,
|
||||
max_concurrent_auto_downloads=self.settings.max_concurrent_auto_downloads,
|
||||
notify_on_new_episodes=self.settings.notify_on_new_episodes,
|
||||
include_completed_anime=self.settings.include_completed_anime
|
||||
)
|
||||
session.add(db_settings)
|
||||
|
||||
session.commit()
|
||||
logger.debug("Saved watchlist settings to database")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings: {e}")
|
||||
logger.error(f"Error saving settings to database: {e}")
|
||||
|
||||
def _to_api_model(self, db_item: WatchlistItemTable) -> WatchlistItem:
|
||||
"""Convert database table model to API response model"""
|
||||
data = db_item.model_dump()
|
||||
data["genres"] = db_item.genres
|
||||
return WatchlistItem(**data)
|
||||
|
||||
def get_all(self, user_id: Optional[str] = None, status: Optional[WatchlistStatus] = None) -> List[WatchlistItem]:
|
||||
"""Get all watchlist items, optionally filtered by user and status"""
|
||||
items = list(self.watchlist.values())
|
||||
|
||||
if user_id:
|
||||
items = [item for item in items if item.user_id == user_id]
|
||||
|
||||
if status:
|
||||
items = [item for item in items if item.status == status]
|
||||
|
||||
# Sort by added_at descending
|
||||
items.sort(key=lambda x: x.added_at, reverse=True)
|
||||
return items
|
||||
with Session(engine) as session:
|
||||
statement = select(WatchlistItemTable)
|
||||
if user_id:
|
||||
statement = statement.where(WatchlistItemTable.user_id == user_id)
|
||||
if status:
|
||||
statement = statement.where(WatchlistItemTable.status == status)
|
||||
|
||||
# Sort by added_at descending
|
||||
statement = statement.order_by(WatchlistItemTable.added_at.desc())
|
||||
|
||||
db_items = session.exec(statement).all()
|
||||
return [self._to_api_model(item) for item in db_items]
|
||||
|
||||
def get_by_id(self, item_id: str) -> Optional[WatchlistItem]:
|
||||
"""Get a watchlist item by ID"""
|
||||
return self.watchlist.get(item_id)
|
||||
"""Get a specific watchlist item by ID"""
|
||||
with Session(engine) as session:
|
||||
db_item = session.get(WatchlistItemTable, item_id)
|
||||
if db_item:
|
||||
return self._to_api_model(db_item)
|
||||
return None
|
||||
|
||||
def get_by_anime_url(self, anime_url: str, user_id: str) -> Optional[WatchlistItem]:
|
||||
"""Get a watchlist item by anime URL and user ID"""
|
||||
for item in self.watchlist.values():
|
||||
if item.anime_url == anime_url and item.user_id == user_id:
|
||||
return item
|
||||
return None
|
||||
with Session(engine) as session:
|
||||
statement = select(WatchlistItemTable).where(
|
||||
WatchlistItemTable.anime_url == anime_url,
|
||||
WatchlistItemTable.user_id == user_id
|
||||
)
|
||||
db_item = session.exec(statement).first()
|
||||
if db_item:
|
||||
return self._to_api_model(db_item)
|
||||
return None
|
||||
|
||||
def create(self, user_id: str, item_data: WatchlistItemCreate) -> WatchlistItem:
|
||||
"""Create a new watchlist item"""
|
||||
# Check if already exists
|
||||
existing = self.get_by_anime_url(item_data.anime_url, user_id)
|
||||
def add(self, user_id: str, item_create: WatchlistItemCreate) -> WatchlistItem:
|
||||
"""Add a new anime to the watchlist"""
|
||||
# Check if already in watchlist for this user
|
||||
existing = self.get_by_anime_url(item_create.anime_url, user_id)
|
||||
if existing:
|
||||
raise ValueError(f"Anime already in watchlist (ID: {existing.id})")
|
||||
return existing
|
||||
|
||||
# Create new item
|
||||
item_id = str(uuid.uuid4())
|
||||
now = datetime.now()
|
||||
with Session(engine) as session:
|
||||
# Create new item
|
||||
db_item = WatchlistItemTable(
|
||||
user_id=user_id,
|
||||
anime_title=item_create.anime_title,
|
||||
anime_url=item_create.anime_url,
|
||||
provider_id=item_create.provider_id,
|
||||
lang=item_create.lang,
|
||||
auto_download=item_create.auto_download,
|
||||
quality_preference=item_create.quality_preference,
|
||||
poster_image=item_create.poster_image,
|
||||
cover_image=item_create.cover_image,
|
||||
synopsis=item_create.synopsis,
|
||||
status=WatchlistStatus.ACTIVE,
|
||||
added_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
last_episode_downloaded=0
|
||||
)
|
||||
db_item.genres = item_create.genres
|
||||
|
||||
watchlist_item = WatchlistItem(
|
||||
id=item_id,
|
||||
user_id=user_id,
|
||||
anime_title=item_data.anime_title,
|
||||
anime_url=item_data.anime_url,
|
||||
provider_id=item_data.provider_id,
|
||||
lang=item_data.lang,
|
||||
auto_download=item_data.auto_download,
|
||||
quality_preference=item_data.quality_preference,
|
||||
status=WatchlistStatus.ACTIVE,
|
||||
poster_image=item_data.poster_image,
|
||||
cover_image=item_data.cover_image,
|
||||
synopsis=item_data.synopsis,
|
||||
genres=item_data.genres,
|
||||
added_at=now,
|
||||
updated_at=now,
|
||||
last_checked=None,
|
||||
last_episode_downloaded=0,
|
||||
total_episodes=None
|
||||
)
|
||||
session.add(db_item)
|
||||
session.commit()
|
||||
session.refresh(db_item)
|
||||
|
||||
logger.info(f"Added {db_item.anime_title} to watchlist for user {user_id}")
|
||||
return self._to_api_model(db_item)
|
||||
|
||||
self.watchlist[item_id] = watchlist_item
|
||||
self._save_watchlist()
|
||||
logger.info(f"Added anime to watchlist: {watchlist_item.anime_title} (ID: {item_id})")
|
||||
return watchlist_item
|
||||
# Alias for backward compatibility if needed
|
||||
add_item = add
|
||||
|
||||
def update(self, item_id: str, update_data) -> Optional[WatchlistItem]:
|
||||
"""Update a watchlist item
|
||||
"""Update a watchlist item"""
|
||||
with Session(engine) as session:
|
||||
db_item = session.get(WatchlistItemTable, item_id)
|
||||
if not db_item:
|
||||
return None
|
||||
|
||||
Args:
|
||||
item_id: Item ID to update
|
||||
update_data: WatchlistItemUpdate object or dict with fields to update
|
||||
"""
|
||||
item = self.watchlist.get(item_id)
|
||||
if not item:
|
||||
return None
|
||||
# Handle both dict and WatchlistItemUpdate
|
||||
if isinstance(update_data, dict):
|
||||
update_dict = update_data
|
||||
else:
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle both dict and WatchlistItemUpdate
|
||||
if isinstance(update_data, dict):
|
||||
update_dict = update_data
|
||||
else:
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
for key, value in update_dict.items():
|
||||
if hasattr(db_item, key):
|
||||
setattr(db_item, key, value)
|
||||
|
||||
db_item.updated_at = datetime.now()
|
||||
|
||||
session.add(db_item)
|
||||
session.commit()
|
||||
session.refresh(db_item)
|
||||
|
||||
logger.info(f"Updated watchlist item: {item_id}")
|
||||
return self._to_api_model(db_item)
|
||||
|
||||
# Update fields
|
||||
for field, value in update_dict.items():
|
||||
if value is not None:
|
||||
setattr(item, field, value)
|
||||
|
||||
item.updated_at = datetime.now()
|
||||
self._save_watchlist()
|
||||
logger.info(f"Updated watchlist item: {item_id}")
|
||||
return item
|
||||
# Alias for backward compatibility
|
||||
update_item = update
|
||||
|
||||
def delete(self, item_id: str) -> bool:
|
||||
"""Delete a watchlist item"""
|
||||
if item_id in self.watchlist:
|
||||
del self.watchlist[item_id]
|
||||
self._save_watchlist()
|
||||
logger.info(f"Deleted watchlist item: {item_id}")
|
||||
"""Remove an item from the watchlist"""
|
||||
with Session(engine) as session:
|
||||
db_item = session.get(WatchlistItemTable, item_id)
|
||||
if not db_item:
|
||||
return False
|
||||
|
||||
session.delete(db_item)
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Deleted item {item_id} from watchlist")
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_check_time(self, item_id: str, last_episode: int) -> Optional[WatchlistItem]:
|
||||
"""Update last_checked time and last_episode_downloaded"""
|
||||
item = self.watchlist.get(item_id)
|
||||
if not item:
|
||||
return None
|
||||
def update_last_checked(self, item_id: str, last_episode: Optional[int] = None):
|
||||
"""Update the last_checked timestamp and optionally last episode for an item"""
|
||||
with Session(engine) as session:
|
||||
db_item = session.get(WatchlistItemTable, item_id)
|
||||
if db_item:
|
||||
db_item.last_checked = datetime.now()
|
||||
if last_episode is not None:
|
||||
db_item.last_episode_downloaded = last_episode
|
||||
session.add(db_item)
|
||||
session.commit()
|
||||
|
||||
item.last_checked = datetime.now()
|
||||
item.last_episode_downloaded = max(item.last_episode_downloaded, last_episode)
|
||||
item.updated_at = datetime.now()
|
||||
self._save_watchlist()
|
||||
return item
|
||||
# Alias for backward compatibility
|
||||
update_check_time = update_last_checked
|
||||
|
||||
def get_settings(self) -> WatchlistSettings:
|
||||
"""Get watchlist settings"""
|
||||
if not self.settings:
|
||||
self.settings = WatchlistSettings()
|
||||
return self.settings
|
||||
def get_due_items(self) -> List[WatchlistItem]:
|
||||
"""Get all items that are due for a check based on settings"""
|
||||
interval = timedelta(hours=self.settings.check_interval_hours)
|
||||
now = datetime.now()
|
||||
|
||||
with Session(engine) as session:
|
||||
statement = select(WatchlistItemTable).where(
|
||||
(WatchlistItemTable.status == WatchlistStatus.ACTIVE)
|
||||
)
|
||||
|
||||
db_items = session.exec(statement).all()
|
||||
|
||||
due_items = []
|
||||
for item in db_items:
|
||||
if not item.last_checked or (item.last_checked + interval) < now:
|
||||
due_items.append(self._to_api_model(item))
|
||||
|
||||
return due_items
|
||||
|
||||
def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
|
||||
"""Update watchlist settings"""
|
||||
"""Update global watchlist settings"""
|
||||
self.settings = settings
|
||||
self._save_settings()
|
||||
logger.info("Updated watchlist settings")
|
||||
return self.settings
|
||||
|
||||
def get_due_for_check(self, check_interval_hours: Optional[int] = None) -> List[WatchlistItem]:
|
||||
"""Get items that are due for checking"""
|
||||
if check_interval_hours is None:
|
||||
check_interval_hours = self.settings.check_interval_hours
|
||||
|
||||
cutoff_time = datetime.now() - timedelta(hours=check_interval_hours)
|
||||
|
||||
due_items = []
|
||||
for item in self.watchlist.values():
|
||||
# Only check active items with auto_download enabled
|
||||
if item.status != WatchlistStatus.ACTIVE or not item.auto_download:
|
||||
continue
|
||||
|
||||
# Check if due
|
||||
if item.last_checked is None or item.last_checked < cutoff_time:
|
||||
due_items.append(item)
|
||||
|
||||
logger.info(f"Found {len(due_items)} items due for check")
|
||||
return due_items
|
||||
|
||||
def get_stats(self, user_id: Optional[str] = None) -> Dict:
|
||||
"""Get watchlist statistics"""
|
||||
def get_stats(self, user_id: str) -> Dict:
|
||||
"""Get statistics for a user's watchlist"""
|
||||
items = self.get_all(user_id=user_id)
|
||||
|
||||
stats = {
|
||||
"total": len(items),
|
||||
"active": len([i for i in items if i.status == WatchlistStatus.ACTIVE]),
|
||||
"paused": len([i for i in items if i.status == WatchlistStatus.PAUSED]),
|
||||
"completed": len([i for i in items if i.status == WatchlistStatus.COMPLETED]),
|
||||
"auto_download_enabled": len([i for i in items if i.auto_download]),
|
||||
"total_items": len(items),
|
||||
"active_items": len([i for i in items if i.status == WatchlistStatus.ACTIVE]),
|
||||
"paused_items": len([i for i in items if i.status == WatchlistStatus.PAUSED]),
|
||||
"completed_items": len([i for i in items if i.status == WatchlistStatus.COMPLETED]),
|
||||
"total_episodes_downloaded": sum(i.last_episode_downloaded for i in items),
|
||||
"providers": {}
|
||||
}
|
||||
|
||||
|
||||
# Count by provider
|
||||
for item in items:
|
||||
provider = item.provider_id
|
||||
stats["providers"][provider] = stats["providers"].get(provider, 0) + 1
|
||||
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"testuser": {
|
||||
"id": "ae5deb822e0d71992900471a7199d0d9",
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"full_name": "Test User",
|
||||
"hashed_password": "$2b$12$gDgt6xCBS4y2FgNrCk0JU.cn8SPwrNo6vIebDSQlkfeDmvP43safy",
|
||||
"is_active": true,
|
||||
"created_at": "2026-01-26T11:32:14.262592",
|
||||
"last_login": "2026-01-26T12:18:26.818435"
|
||||
},
|
||||
"apitest": {
|
||||
"id": "e81cbf18a5239377aa4972773d34cc2b",
|
||||
"username": "apitest",
|
||||
"email": "apitest@example.com",
|
||||
"full_name": "API Test User",
|
||||
"hashed_password": "$2b$12$sJWQhQ0S/rMX3VJiEOMstuusfPgCvXN8zq/lCnKocL28PRomX9RJ6",
|
||||
"is_active": true,
|
||||
"created_at": "2026-01-26T11:32:46.943188",
|
||||
"last_login": "2026-01-26T11:32:47.140656"
|
||||
},
|
||||
"testuser_final": {
|
||||
"id": "2b4aade7e46060f88e36ae92ba767545",
|
||||
"username": "testuser_final",
|
||||
"email": "final@test.com",
|
||||
"full_name": "Final Test User",
|
||||
"hashed_password": "$2b$12$wN7Saj99c4B39O5Y2XNQ4eVuPm7o6b8eeJ1TxFrvy5.g7ycyh9rKm",
|
||||
"is_active": true,
|
||||
"created_at": "2026-01-26T11:33:45.726090",
|
||||
"last_login": "2026-01-26T11:33:46.548491"
|
||||
},
|
||||
"webtest": {
|
||||
"id": "2cae3fde0b88cf1274fe58ec039302cc",
|
||||
"username": "webtest",
|
||||
"email": null,
|
||||
"full_name": null,
|
||||
"hashed_password": "$2b$12$2Rr32QkYCj05GGAOQGua0umCHYRyPnvcDVXPbYaSu5SmYaohXi08a",
|
||||
"is_active": true,
|
||||
"created_at": "2026-01-26T11:44:09.995999",
|
||||
"last_login": "2026-01-26T11:44:10.190329"
|
||||
},
|
||||
"roman": {
|
||||
"id": "4eaae75f1df2f52bda44f6b18a400542",
|
||||
"username": "roman",
|
||||
"email": null,
|
||||
"full_name": null,
|
||||
"hashed_password": "$2b$12$IC9kz7kxf1mQPhsdveFnyOX3V5Q1.pB9/uqCKWI7nhn.SYamtvxCC",
|
||||
"is_active": true,
|
||||
"created_at": "2026-01-26T12:15:58.008205",
|
||||
"last_login": "2026-02-27T09:06:22.312570"
|
||||
},
|
||||
"testuser999": {
|
||||
"id": "f9abf4b8aa96d5116807ac1cf8540418",
|
||||
"username": "testuser999",
|
||||
"email": null,
|
||||
"full_name": null,
|
||||
"hashed_password": "$2b$12$y2uy62IR0xVmCcUmQ8gL6.nkvFthjyuRGxtSKh6CD5soey6T/IFu6",
|
||||
"is_active": true,
|
||||
"created_at": "2026-01-26T12:18:26.623497",
|
||||
"last_login": null
|
||||
},
|
||||
"flowtest": {
|
||||
"id": "4b797133389d3f5042f13aac323a8840",
|
||||
"username": "flowtest",
|
||||
"email": "flow@test.com",
|
||||
"full_name": null,
|
||||
"hashed_password": "$2b$12$Dcb7fKZPycLRsW851m9pk.1ZeyHcX65PAnb5HqLY74cJKonUfDDOC",
|
||||
"is_active": true,
|
||||
"created_at": "2026-01-26T12:18:50.138613",
|
||||
"last_login": "2026-01-26T12:18:50.332004"
|
||||
},
|
||||
"e2etest": {
|
||||
"id": "37a97310cedfe6ae001033c2b9832f6c",
|
||||
"username": "e2etest",
|
||||
"email": null,
|
||||
"full_name": null,
|
||||
"hashed_password": "$2b$12$uV9AW1qrbLC2tOCk1Gs4x.clk1v7jPNteHmn/Nby/Lelopb9Ce60m",
|
||||
"is_active": true,
|
||||
"created_at": "2026-02-26T16:01:01.051127",
|
||||
"last_login": "2026-02-26T16:11:48.431566"
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"2293bca2-c1c2-4e4f-8862-c4a6601f2b6f": {
|
||||
"id": "2293bca2-c1c2-4e4f-8862-c4a6601f2b6f",
|
||||
"user_id": "test_user_1",
|
||||
"anime_title": "Test Anime",
|
||||
"anime_url": "https://anime-sama.si/catalogue/test/vostfr/",
|
||||
"provider_id": "animesama",
|
||||
"lang": "vostfr",
|
||||
"last_checked": "2026-02-28T00:29:13.675660",
|
||||
"last_episode_downloaded": 0,
|
||||
"total_episodes": null,
|
||||
"auto_download": true,
|
||||
"quality_preference": "auto",
|
||||
"status": "active",
|
||||
"poster_image": null,
|
||||
"cover_image": null,
|
||||
"synopsis": null,
|
||||
"genres": [],
|
||||
"added_at": "2026-01-29T21:53:38.078765",
|
||||
"updated_at": "2026-02-28T00:29:13.675679"
|
||||
},
|
||||
"fd62e169-46de-4bdc-8966-53329bcc81bb": {
|
||||
"id": "fd62e169-46de-4bdc-8966-53329bcc81bb",
|
||||
"user_id": "4eaae75f1df2f52bda44f6b18a400542",
|
||||
"anime_title": "Frieren",
|
||||
"anime_url": "https://anime-sama.tv/catalogue/frieren/saison1/vostfr/",
|
||||
"provider_id": "anime-sama",
|
||||
"lang": "vostfr",
|
||||
"last_checked": null,
|
||||
"last_episode_downloaded": 0,
|
||||
"total_episodes": null,
|
||||
"auto_download": true,
|
||||
"quality_preference": "auto",
|
||||
"status": "active",
|
||||
"poster_image": "https://raw.githubusercontent.com/Anime-Sama/IMG/img/contenu/frieren0.jpg",
|
||||
"cover_image": null,
|
||||
"synopsis": null,
|
||||
"genres": [],
|
||||
"added_at": "2026-02-28T09:20:00.841741",
|
||||
"updated_at": "2026-02-28T09:20:00.841741"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"check_interval_hours": 6,
|
||||
"auto_download_enabled": true,
|
||||
"check_interval_hours": 12,
|
||||
"auto_download_enabled": false,
|
||||
"max_concurrent_auto_downloads": 2,
|
||||
"notify_on_new_episodes": false,
|
||||
"include_completed_anime": false
|
||||
|
||||
Generated
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "ohm-streaming",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ohm-streaming",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "ohm-streaming",
|
||||
"version": "1.0.0",
|
||||
"description": "Ohm Stream Downloader - Frontend JavaScript Tests",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: './tests/e2e/global-setup.ts',
|
||||
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Capture screenshot on failure */
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
/* Video recording on failure */
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'venv/bin/uvicorn main:app --host 0.0.0.0 --port 3000',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
});
|
||||
@@ -10,6 +10,8 @@ aiohttp==3.11.11
|
||||
beautifulsoup4==4.12.3
|
||||
lxml==5.3.0
|
||||
jieba==0.42.1
|
||||
sqlmodel==0.0.22
|
||||
PyYAML==6.0.1
|
||||
|
||||
# Testing dependencies
|
||||
pytest==8.3.4
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
export PYTHONPATH=.
|
||||
exec ./venv/bin/python3 -m uvicorn main:app --host 0.0.0.0 --port 3000 > /root/ohm_server.log 2>&1
|
||||
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
import sys
|
||||
from sqlmodel import Session, select, create_engine, inspect
|
||||
|
||||
# Add root directory to sys.path
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
from app.database import engine
|
||||
from app.models.auth import UserTable
|
||||
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable
|
||||
from app.models.favorites import FavoriteTable
|
||||
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
||||
|
||||
def audit_db():
|
||||
print("--- Ohm Stream Downloader: SQL Database Audit ---")
|
||||
|
||||
if not os.path.exists("ohm_streaming.db"):
|
||||
print("ERROR: ohm_streaming.db not found!")
|
||||
return
|
||||
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
print(f"Tables found: {', '.join(tables)}")
|
||||
|
||||
expected_tables = ["users", "watchlist_items", "watchlist_settings", "favorites", "sonarr_mappings", "sonarr_config", "alembic_version"]
|
||||
missing = [t for t in expected_tables if t not in tables]
|
||||
if missing:
|
||||
print(f"WARNING: Missing tables: {', '.join(missing)}")
|
||||
else:
|
||||
print("SUCCESS: All core tables are present.")
|
||||
|
||||
with Session(engine) as session:
|
||||
# Check users
|
||||
users_count = len(session.exec(select(UserTable)).all())
|
||||
print(f"Users: {users_count}")
|
||||
|
||||
# Check watchlist
|
||||
watchlist_count = len(session.exec(select(WatchlistItemTable)).all())
|
||||
print(f"Watchlist Items: {watchlist_count}")
|
||||
|
||||
# Check settings
|
||||
settings_count = len(session.exec(select(WatchlistSettingsTable)).all())
|
||||
print(f"Watchlist Settings entries: {settings_count}")
|
||||
|
||||
# Check favorites
|
||||
fav_count = len(session.exec(select(FavoriteTable)).all())
|
||||
print(f"Favorites: {fav_count}")
|
||||
|
||||
# Check Sonarr
|
||||
sonarr_map_count = len(session.exec(select(SonarrMappingTable)).all())
|
||||
print(f"Sonarr Mappings: {sonarr_map_count}")
|
||||
|
||||
# Sample data check
|
||||
if fav_count > 0:
|
||||
sample_fav = session.exec(select(FavoriteTable).limit(1)).first()
|
||||
print(f"Sample Favorite: {sample_fav.title} ({sample_fav.provider})")
|
||||
|
||||
if __name__ == "__main__":
|
||||
audit_db()
|
||||
@@ -0,0 +1,276 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from sqlmodel import Session, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
import sys
|
||||
|
||||
# Add the root directory to sys.path to import app modules
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.database import engine, create_db_and_tables
|
||||
from app.models.auth import UserTable
|
||||
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable, WatchlistStatus, QualityPreference
|
||||
from app.models.favorites import FavoriteTable
|
||||
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
||||
|
||||
def migrate_users(session: Session):
|
||||
path = Path("config/users.json")
|
||||
if not path.exists():
|
||||
print("No users.json found.")
|
||||
return
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
users_data = data.get("users", {})
|
||||
count = 0
|
||||
for user_id, user_info in users_data.items():
|
||||
existing = session.get(UserTable, user_id)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# Parse dates
|
||||
created_at = datetime.now()
|
||||
if "created_at" in user_info:
|
||||
try:
|
||||
created_at = datetime.fromisoformat(user_info["created_at"])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
last_login = None
|
||||
if "last_login" in user_info and user_info["last_login"]:
|
||||
try:
|
||||
last_login = datetime.fromisoformat(user_info["last_login"])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
user = UserTable(
|
||||
id=user_id,
|
||||
username=user_info.get("username", "unknown"),
|
||||
email=user_info.get("email"),
|
||||
hashed_password=user_info.get("hashed_password", ""),
|
||||
is_active=user_info.get("is_active", True),
|
||||
created_at=created_at,
|
||||
last_login=last_login
|
||||
)
|
||||
session.add(user)
|
||||
count += 1
|
||||
session.commit()
|
||||
print(f"Migrated {count} users.")
|
||||
|
||||
def migrate_watchlist(session: Session):
|
||||
path = Path("config/watchlist.json")
|
||||
if not path.exists():
|
||||
print("No watchlist.json found.")
|
||||
return
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
count = 0
|
||||
for user_id, items in data.items():
|
||||
for item in items:
|
||||
existing = session.get(WatchlistItemTable, item.get("id"))
|
||||
if existing:
|
||||
continue
|
||||
|
||||
last_checked = None
|
||||
if "last_checked" in item and item["last_checked"]:
|
||||
try:
|
||||
last_checked = datetime.fromisoformat(item["last_checked"])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
added_at = datetime.now()
|
||||
if "added_at" in item:
|
||||
try:
|
||||
added_at = datetime.fromisoformat(item["added_at"])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
updated_at = datetime.now()
|
||||
if "updated_at" in item:
|
||||
try:
|
||||
updated_at = datetime.fromisoformat(item["updated_at"])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
wl_item = WatchlistItemTable(
|
||||
id=item["id"],
|
||||
user_id=user_id,
|
||||
anime_title=item["anime_title"],
|
||||
anime_url=item["anime_url"],
|
||||
provider_id=item["provider_id"],
|
||||
lang=item.get("lang", "vostfr"),
|
||||
last_checked=last_checked,
|
||||
last_episode_downloaded=item.get("last_episode_downloaded", 0),
|
||||
total_episodes=item.get("total_episodes"),
|
||||
auto_download=item.get("auto_download", True),
|
||||
quality_preference=item.get("quality_preference", QualityPreference.AUTO),
|
||||
status=item.get("status", WatchlistStatus.ACTIVE),
|
||||
poster_image=item.get("poster_image"),
|
||||
cover_image=item.get("cover_image"),
|
||||
synopsis=item.get("synopsis"),
|
||||
genres=item.get("genres", []),
|
||||
added_at=added_at,
|
||||
updated_at=updated_at
|
||||
)
|
||||
session.add(wl_item)
|
||||
count += 1
|
||||
session.commit()
|
||||
print(f"Migrated {count} watchlist items.")
|
||||
|
||||
def migrate_watchlist_settings(session: Session):
|
||||
path = Path("config/watchlist_settings.json")
|
||||
if not path.exists():
|
||||
print("No watchlist_settings.json found.")
|
||||
return
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
settings = json.load(f)
|
||||
|
||||
count = 0
|
||||
# Treat as global settings for "default" user
|
||||
user_id = "default"
|
||||
existing = session.exec(select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == user_id)).first()
|
||||
if not existing:
|
||||
setting_row = WatchlistSettingsTable(
|
||||
user_id=user_id,
|
||||
check_interval_hours=settings.get("check_interval_hours", 6),
|
||||
auto_download_enabled=settings.get("auto_download_enabled", True),
|
||||
max_concurrent_auto_downloads=settings.get("max_concurrent_auto_downloads", 2),
|
||||
notify_on_new_episodes=settings.get("notify_on_new_episodes", False),
|
||||
include_completed_anime=settings.get("include_completed_anime", False)
|
||||
)
|
||||
session.add(setting_row)
|
||||
count += 1
|
||||
session.commit()
|
||||
print(f"Migrated {count} watchlist settings.")
|
||||
|
||||
def migrate_favorites(session: Session):
|
||||
path = Path("data/favorites.json")
|
||||
if not path.exists():
|
||||
print("No favorites.json found.")
|
||||
return
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
print("Invalid favorites.json.")
|
||||
return
|
||||
|
||||
count = 0
|
||||
for fav_id, fav in data.items():
|
||||
existing = session.exec(select(FavoriteTable).where(FavoriteTable.anime_id == fav_id)).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
created_at = datetime.now()
|
||||
if "created_at" in fav:
|
||||
try:
|
||||
created_at = datetime.fromisoformat(fav["created_at"])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
updated_at = datetime.now()
|
||||
if "updated_at" in fav:
|
||||
try:
|
||||
updated_at = datetime.fromisoformat(fav["updated_at"])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
fav_row = FavoriteTable(
|
||||
anime_id=fav_id,
|
||||
user_id="default", # Favorites were global
|
||||
title=fav.get("title", ""),
|
||||
url=fav.get("url", ""),
|
||||
provider=fav.get("provider", ""),
|
||||
poster_url=fav.get("poster_url"),
|
||||
anime_metadata=fav.get("metadata", {}),
|
||||
created_at=created_at,
|
||||
updated_at=updated_at
|
||||
)
|
||||
session.add(fav_row)
|
||||
count += 1
|
||||
session.commit()
|
||||
print(f"Migrated {count} favorites.")
|
||||
|
||||
def migrate_sonarr(session: Session):
|
||||
# Config
|
||||
path_config = Path("config/sonarr.json")
|
||||
if path_config.exists():
|
||||
with open(path_config, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
existing = session.exec(select(SonarrConfigTable)).first()
|
||||
if not existing:
|
||||
conf = SonarrConfigTable(
|
||||
webhook_enabled=data.get("webhook_enabled", False),
|
||||
webhook_secret=data.get("webhook_secret"),
|
||||
auto_download_enabled=data.get("auto_download_enabled", True),
|
||||
default_language=data.get("default_language", "vostfr"),
|
||||
default_quality=data.get("default_quality"),
|
||||
default_provider=data.get("default_provider", "anime-sama"),
|
||||
verify_hmac=data.get("verify_hmac", False),
|
||||
log_webhooks=data.get("log_webhooks", True)
|
||||
)
|
||||
session.add(conf)
|
||||
session.commit()
|
||||
print("Migrated Sonarr config.")
|
||||
|
||||
# Mappings
|
||||
path_maps = Path("config/sonarr_mappings.json")
|
||||
if path_maps.exists():
|
||||
with open(path_maps, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
count = 0
|
||||
for map_id, mapping in data.items():
|
||||
existing = session.exec(select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == int(map_id))).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
created_at = datetime.now()
|
||||
if "created_at" in mapping:
|
||||
try:
|
||||
created_at = datetime.fromisoformat(mapping["created_at"])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
updated_at = datetime.now()
|
||||
if "updated_at" in mapping:
|
||||
try:
|
||||
updated_at = datetime.fromisoformat(mapping["updated_at"])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
map_row = SonarrMappingTable(
|
||||
user_id="default",
|
||||
sonarr_series_id=mapping.get("sonarr_series_id", int(map_id)),
|
||||
sonarr_title=mapping.get("sonarr_title", ""),
|
||||
anime_provider=mapping.get("anime_provider", ""),
|
||||
anime_url=mapping.get("anime_url", ""),
|
||||
anime_title=mapping.get("anime_title", ""),
|
||||
lang=mapping.get("lang", "vostfr"),
|
||||
quality_preference=mapping.get("quality_preference"),
|
||||
auto_download=mapping.get("auto_download", True),
|
||||
created_at=created_at,
|
||||
updated_at=updated_at
|
||||
)
|
||||
session.add(map_row)
|
||||
count += 1
|
||||
session.commit()
|
||||
print(f"Migrated {count} Sonarr mappings.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_db_and_tables()
|
||||
with Session(engine) as session:
|
||||
migrate_users(session)
|
||||
migrate_watchlist(session)
|
||||
migrate_watchlist_settings(session)
|
||||
migrate_favorites(session)
|
||||
migrate_sonarr(session)
|
||||
print("Data migration complete.")
|
||||
@@ -0,0 +1,17 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.getcwd())
|
||||
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||
|
||||
async def test_search():
|
||||
dl = FS7Downloader()
|
||||
print("Testing FS7 Search...")
|
||||
results = await dl.search_anime("Breaking Bad")
|
||||
for r in results:
|
||||
print(f"Title: {r['title']}")
|
||||
print(f"Image: {r['cover_image']}")
|
||||
print("-" * 20)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_search())
|
||||
+1140
-1585
File diff suppressed because it is too large
Load Diff
@@ -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`
|
||||
+31
-19
@@ -62,16 +62,13 @@ async function searchAnimeDetails(query, malId = null) {
|
||||
const providersData = await getProvidersInfo();
|
||||
|
||||
// Build results HTML
|
||||
const streamingParts = [
|
||||
`<div class="streaming-results-header">
|
||||
<h3>🎬 Résultats de streaming</h3>
|
||||
</div>
|
||||
<div class="search-results" style="margin-top: 20px;">`
|
||||
];
|
||||
const streamingParts = [];
|
||||
let hasResults = false;
|
||||
|
||||
// Display results from each provider - render all cards in parallel
|
||||
for (const [providerId, results] of Object.entries(streamingData.value.results)) {
|
||||
if (results && results.length > 0) {
|
||||
hasResults = true;
|
||||
const provider = providersData.anime_providers[providerId];
|
||||
|
||||
// Render all cards for this provider
|
||||
@@ -81,8 +78,17 @@ async function searchAnimeDetails(query, malId = null) {
|
||||
}
|
||||
}
|
||||
|
||||
streamingParts.push('</div>');
|
||||
streamingHtml = streamingParts.join('');
|
||||
// Only add header and wrapper if we have results
|
||||
if (hasResults) {
|
||||
streamingParts.unshift(
|
||||
`<div class="streaming-results-header">
|
||||
<h3>🎬 Résultats de streaming</h3>
|
||||
</div>
|
||||
<div class="search-results" style="margin-top: 20px;">`
|
||||
);
|
||||
streamingParts.push('</div>');
|
||||
streamingHtml = streamingParts.join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Display results
|
||||
@@ -150,16 +156,13 @@ async function getProviderSearchResults(query) {
|
||||
}
|
||||
|
||||
// Build results HTML
|
||||
const htmlParts = [
|
||||
`<div class="streaming-results-header">
|
||||
<h3>🎬 Résultats de streaming</h3>
|
||||
</div>
|
||||
<div class="search-results" style="margin-top: 20px;">`
|
||||
];
|
||||
const htmlParts = [];
|
||||
let hasResults = false;
|
||||
|
||||
// Display results from each provider
|
||||
for (const [providerId, results] of Object.entries(data.results)) {
|
||||
if (results && results.length > 0) {
|
||||
hasResults = true;
|
||||
const providersData = await getProvidersInfo();
|
||||
const provider = providersData.anime_providers[providerId];
|
||||
|
||||
@@ -170,7 +173,16 @@ async function getProviderSearchResults(query) {
|
||||
}
|
||||
}
|
||||
|
||||
htmlParts.push('</div>');
|
||||
// Only add header and wrapper if we have results
|
||||
if (hasResults) {
|
||||
htmlParts.unshift(
|
||||
`<div class="streaming-results-header">
|
||||
<h3>🎬 Résultats de streaming</h3>
|
||||
</div>
|
||||
<div class="search-results" style="margin-top: 20px;">`
|
||||
);
|
||||
htmlParts.push('</div>');
|
||||
}
|
||||
|
||||
return htmlParts.join('');
|
||||
|
||||
@@ -256,10 +268,10 @@ function renderAnimeDetails(anime) {
|
||||
` : ''}
|
||||
|
||||
<div class="anime-details-actions">
|
||||
<a href="${escapeHtml(anime.url)}" target="_blank" class="btn-secondary btn-small">
|
||||
<a href="${escapeHtml(anime.url)}" target="_blank" class="btn btn-secondary btn-small">
|
||||
🔗 Voir sur MAL
|
||||
</a>
|
||||
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn-primary btn-small">
|
||||
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn btn-primary btn-small">
|
||||
📥 Télécharger
|
||||
</button>
|
||||
</div>
|
||||
@@ -279,7 +291,7 @@ function renderAnimeDetails(anime) {
|
||||
<div class="anime-details-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">📖 Synopsis</h3>
|
||||
<button onclick="translateSynopsis('${synopsisId}', this)" class="btn-secondary btn-small" style="font-size: 12px;">
|
||||
<button onclick="translateSynopsis('${synopsisId}', this)" class="btn btn-secondary btn-small" style="font-size: 12px;">
|
||||
🌐 Traduire en français
|
||||
</button>
|
||||
</div>
|
||||
@@ -393,7 +405,7 @@ function renderStreamingResult(result, query) {
|
||||
${episodes.length > 20 ? `<option disabled>... et ${episodes.length - 20} autres</option>` : ''}
|
||||
</select>
|
||||
|
||||
<button class="btn-primary btn-small streaming-download-btn" onclick="downloadSelectedEpisode(this)">
|
||||
<button class="btn btn-primary btn-small streaming-download-btn" onclick="downloadSelectedEpisode(this)">
|
||||
📥 Télécharger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+9
-629
@@ -1,640 +1,20 @@
|
||||
/**
|
||||
* Anime search and episode management
|
||||
* Anime Search & Releases (Legacy - Partially modernized to HTMX)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Display search results
|
||||
*/
|
||||
async function displaySearchResults(data, lang) {
|
||||
const resultsContainer = document.getElementById('searchResults');
|
||||
const providers = await getProvidersInfo();
|
||||
|
||||
let totalResults = 0;
|
||||
let htmlPromises = [];
|
||||
|
||||
for (const [providerId, results] of Object.entries(data.results)) {
|
||||
if (results && results.length > 0) {
|
||||
totalResults += results.length;
|
||||
|
||||
results.forEach(anime => {
|
||||
const providerInfo = providers.anime_providers[providerId];
|
||||
// Collect promises for async rendering
|
||||
htmlPromises.push(renderAnimeCard(anime, providerId, providerInfo, lang));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (totalResults === 0) {
|
||||
resultsContainer.innerHTML = '<div class="no-results">Aucun résultat trouvé</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for all cards to be rendered
|
||||
const htmlSegments = await Promise.all(htmlPromises);
|
||||
resultsContainer.innerHTML = htmlSegments.join('');
|
||||
|
||||
// Auto-load seasons for providers that support them
|
||||
// Stagger the requests to avoid overwhelming the server
|
||||
let delayCounter = 0;
|
||||
for (const [providerId, results] of Object.entries(data.results)) {
|
||||
if (results && results.length > 0) {
|
||||
results.forEach((anime, index) => {
|
||||
// Stagger requests: 500ms delay between each anime
|
||||
setTimeout(() => {
|
||||
// Try to load seasons first (if provider supports them)
|
||||
if (anime.url) {
|
||||
loadSeasonsForAnime(providerId, encodeURIComponent(anime.url));
|
||||
}
|
||||
}, 500 * index);
|
||||
delayCounter++;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render anime card HTML
|
||||
*/
|
||||
async function renderAnimeCard(anime, providerId, providerInfo, lang) {
|
||||
const metadataHtml = renderAnimeMetadata(anime.metadata);
|
||||
|
||||
// Check if provider supports seasons using helper function
|
||||
const supportsSeasons = await providerSupportsSeasons(providerId, anime.url);
|
||||
|
||||
const seasonSelectHtml = supportsSeasons ? `
|
||||
<select id="seasons-${providerId}-${encodeURIComponent(anime.url)}" onchange="handleSeasonChange('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')" style="margin-bottom: 8px; width: 100%; padding: 8px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; color: #fff; font-size: 14px;">
|
||||
<option value="">Chargement des saisons...</option>
|
||||
</select>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<div class="anime-card" id="anime-${providerId}-${encodeURIComponent(anime.url)}">
|
||||
<div class="anime-card-header">
|
||||
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
||||
<div class="anime-card-provider">${providerInfo?.icon || ''} ${providerInfo?.name || providerId}</div>
|
||||
</div>
|
||||
${metadataHtml}
|
||||
<div class="anime-card-actions">
|
||||
${seasonSelectHtml}
|
||||
<select id="episodes-${providerId}-${encodeURIComponent(anime.url)}"
|
||||
onclick="${!supportsSeasons ? `loadEpisodesForAnime('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')` : ''}"
|
||||
onchange="${!supportsSeasons ? `loadEpisodesForAnime('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')` : ''}">
|
||||
<option value="">${supportsSeasons ? 'Sélectionner une saison d\'abord' : 'Cliquez pour charger les épisodes...'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="anime-card-actions" id="actions-${providerId}-${encodeURIComponent(anime.url)}" style="display:none;">
|
||||
<button class="btn-primary" onclick="handleDownloadEpisode('${encodeURIComponent(anime.url)}', '${providerId}', '${lang}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Télécharger
|
||||
</button>
|
||||
<button class="btn-primary" style="background: linear-gradient(45deg, #ff6b6b, #ffa500);" onclick="handleDownloadSeason('${encodeURIComponent(anime.url)}', '${lang}')" title="Télécharger toute la saison">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||
</svg>
|
||||
Toute la saison
|
||||
</button>
|
||||
<button class="btn-secondary" onclick="handleAddToWatchlist('${encodeURIComponent(anime.url)}', '${providerId}')"
|
||||
data-watchlist-url="${encodeURIComponent(anime.url)}"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; padding: 6px 16px; font-size: 13px; border-radius: 6px; cursor: pointer; transition: all 0.2s;"
|
||||
onmouseover="this.style.transform='scale(1.05)'"
|
||||
onmouseout="this.style.transform='scale(1)'">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;vertical-align:middle;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364 0z"></path>
|
||||
</svg>
|
||||
<span style="font-weight:500;">+ Suivre</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render anime metadata
|
||||
*/
|
||||
function renderAnimeMetadata(metadata) {
|
||||
if (!metadata) return '';
|
||||
|
||||
let metaParts = [];
|
||||
|
||||
if (metadata.release_year) metaParts.push(`📅 ${metadata.release_year}`);
|
||||
if (metadata.rating) metaParts.push(`⭐ ${metadata.rating}`);
|
||||
if (metadata.genres && metadata.genres.length > 0) metaParts.push(`🏷️ ${metadata.genres.slice(0, 3).join(', ')}`);
|
||||
if (metadata.total_episodes) metaParts.push(`📺 ${metadata.total_episodes} épisodes`);
|
||||
if (metadata.status) metaParts.push(`📡 ${metadata.status === 'Ongoing' ? 'En cours' : 'Terminé'}`);
|
||||
|
||||
let html = '';
|
||||
|
||||
if (metaParts.length > 0) {
|
||||
html += `
|
||||
<div class="anime-metadata">
|
||||
${metaParts.join(' • ')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (metadata.synopsis) {
|
||||
html += `
|
||||
<details class="anime-synopsis">
|
||||
<summary>📖 Synopsis</summary>
|
||||
<p>${escapeHtml(metadata.synopsis)}</p>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load seasons for anime (if provider supports it)
|
||||
*/
|
||||
async function loadSeasonsForAnime(providerId, encodedUrl) {
|
||||
const url = decodeURIComponent(encodedUrl);
|
||||
const seasonSelectId = `seasons-${providerId}-${encodedUrl}`;
|
||||
|
||||
const seasonSelectElement = document.getElementById(seasonSelectId);
|
||||
if (!seasonSelectElement) {
|
||||
console.log('Season select element not found:', seasonSelectId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if provider supports seasons
|
||||
const supportsSeasons = await providerSupportsSeasons(providerId, url);
|
||||
if (!supportsSeasons) {
|
||||
console.log('Provider does not support seasons:', providerId);
|
||||
seasonSelectElement.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Loading seasons for:', url, 'Element:', seasonSelectId);
|
||||
|
||||
// Mark as loading to prevent duplicate requests
|
||||
if (seasonSelectElement.dataset.loading === 'true') {
|
||||
console.log('Season loading already in progress, skipping...');
|
||||
return;
|
||||
}
|
||||
seasonSelectElement.dataset.loading = 'true';
|
||||
|
||||
async function loadAnimeReleases() {
|
||||
// Keep this for now as it's not yet fully HTMX
|
||||
console.log('Loading anime releases...');
|
||||
try {
|
||||
// Add timeout to the fetch
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 second timeout
|
||||
|
||||
const response = await fetch(`${API_BASE}/anime/seasons?url=${encodeURIComponent(url)}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.seasons && data.seasons.length > 0) {
|
||||
seasonSelectElement.innerHTML = '<option value="">Sélectionner une saison</option>';
|
||||
|
||||
data.seasons.forEach(season => {
|
||||
const option = document.createElement('option');
|
||||
option.value = season.url;
|
||||
const episodeText = season.episode_count ?
|
||||
`${season.episode_count} épisodes` :
|
||||
'Chargement...';
|
||||
option.textContent = `${season.title} (${episodeText})`;
|
||||
option.dataset.seasonNum = season.season;
|
||||
seasonSelectElement.appendChild(option);
|
||||
});
|
||||
|
||||
console.log(`Loaded ${data.seasons.length} seasons`);
|
||||
seasonSelectElement.style.display = 'block';
|
||||
} else {
|
||||
// No seasons found, hide season selector and load episodes directly
|
||||
console.log('No seasons found, hiding selector');
|
||||
seasonSelectElement.style.display = 'none';
|
||||
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to load seasons:', response.status);
|
||||
seasonSelectElement.style.display = 'none';
|
||||
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.error('Season loading timeout');
|
||||
seasonSelectElement.innerHTML = '<option value="">⏱️ Timeout - Réessayez</option>';
|
||||
// Add retry functionality
|
||||
seasonSelectElement.disabled = false;
|
||||
seasonSelectElement.onclick = () => {
|
||||
seasonSelectElement.dataset.loading = 'false';
|
||||
seasonSelectElement.onclick = null;
|
||||
loadSeasonsForAnime(providerId, encodedUrl);
|
||||
};
|
||||
} else {
|
||||
console.error('Error loading seasons:', error);
|
||||
seasonSelectElement.style.display = 'none';
|
||||
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
|
||||
}
|
||||
} finally {
|
||||
seasonSelectElement.dataset.loading = 'false';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle season selection change
|
||||
*/
|
||||
async function handleSeasonChange(providerId, encodedUrl, lang) {
|
||||
const seasonSelectId = `seasons-${providerId}-${encodedUrl}`;
|
||||
const seasonSelectElement = document.getElementById(seasonSelectId);
|
||||
|
||||
const selectedSeasonUrl = seasonSelectElement.value;
|
||||
const encodedSeasonUrl = encodeURIComponent(selectedSeasonUrl);
|
||||
|
||||
if (!selectedSeasonUrl) {
|
||||
// Clear episodes if no season selected
|
||||
const episodeSelectId = `episodes-${providerId}-${encodedUrl}`;
|
||||
const episodeSelectElement = document.getElementById(episodeSelectId);
|
||||
episodeSelectElement.innerHTML = '<option value="">Sélectionner une saison d\'abord</option>';
|
||||
episodeSelectElement.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the episode select element (it's based on the original anime URL)
|
||||
const episodeSelectId = `episodes-${providerId}-${encodedUrl}`;
|
||||
const selectElement = document.getElementById(episodeSelectId);
|
||||
|
||||
if (!selectElement) {
|
||||
console.error('Episode select element not found:', episodeSelectId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
selectElement.innerHTML = '<option value="">Chargement...</option>';
|
||||
selectElement.disabled = false;
|
||||
|
||||
try {
|
||||
// Load episodes for the selected season
|
||||
const data = await loadEpisodes(selectedSeasonUrl, lang);
|
||||
|
||||
if (data.episodes && data.episodes.length > 0) {
|
||||
selectElement.innerHTML = '<option value="">Sélectionner un épisode</option>';
|
||||
data.episodes.forEach(ep => {
|
||||
const option = document.createElement('option');
|
||||
option.value = ep.url;
|
||||
option.textContent = `Épisode ${ep.episode}`;
|
||||
selectElement.appendChild(option);
|
||||
});
|
||||
|
||||
// Show download buttons
|
||||
const actionsId = `actions-${providerId}-${encodedUrl}`;
|
||||
const actionsDiv = document.getElementById(actionsId);
|
||||
actionsDiv.style.display = 'flex';
|
||||
} else {
|
||||
selectElement.innerHTML = '<option value="">Aucun épisode disponible</option>';
|
||||
selectElement.disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading episodes:', error);
|
||||
selectElement.innerHTML = '<option value="">Erreur de chargement</option>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load episodes for an anime
|
||||
*/
|
||||
async function loadEpisodesForAnime(providerId, encodedUrl, lang) {
|
||||
const url = decodeURIComponent(encodedUrl);
|
||||
const selectId = `episodes-${providerId}-${encodedUrl}`;
|
||||
const actionsId = `actions-${providerId}-${encodedUrl}`;
|
||||
|
||||
const selectElement = document.getElementById(selectId);
|
||||
if (!selectElement) return;
|
||||
|
||||
selectElement.innerHTML = '<option value="">Chargement...</option>';
|
||||
|
||||
try {
|
||||
const data = await loadEpisodes(url, lang);
|
||||
|
||||
if (data.episodes && data.episodes.length > 0) {
|
||||
selectElement.innerHTML = '<option value="">Sélectionner un épisode</option>';
|
||||
data.episodes.forEach(ep => {
|
||||
const option = document.createElement('option');
|
||||
option.value = ep.url;
|
||||
option.textContent = `Épisode ${ep.episode}`;
|
||||
selectElement.appendChild(option);
|
||||
});
|
||||
|
||||
// Show download buttons
|
||||
const actionsDiv = document.getElementById(actionsId);
|
||||
actionsDiv.style.display = 'flex';
|
||||
} else {
|
||||
selectElement.innerHTML = '<option value="">Aucun épisode disponible</option>';
|
||||
selectElement.disabled = true;
|
||||
|
||||
// Add warning message
|
||||
const card = document.getElementById(`anime-${providerId}-${encodedUrl}`);
|
||||
if (card) {
|
||||
const warning = document.createElement('div');
|
||||
warning.style.cssText = 'margin-top: 10px; padding: 10px; background: rgba(255, 100, 100, 0.2); border-radius: 6px; font-size: 12px; color: #ff6b6b;';
|
||||
warning.textContent = '⚠️ Aucun épisode trouvé. Essayez une recherche exacte ou un autre fournisseur.';
|
||||
card.appendChild(warning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading episodes:', error);
|
||||
selectElement.innerHTML = '<option value="">Erreur de chargement</option>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle episode download
|
||||
*/
|
||||
async function handleDownloadEpisode(encodedUrl, providerId, lang) {
|
||||
const url = decodeURIComponent(encodedUrl);
|
||||
const selectId = `episodes-${providerId}-${encodedUrl}`;
|
||||
const selectElement = document.getElementById(selectId);
|
||||
|
||||
const episodeUrl = selectElement.value;
|
||||
if (!episodeUrl) {
|
||||
alert('Veuillez sélectionner un épisode');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadEpisode(episodeUrl);
|
||||
loadDownloads();
|
||||
alert('Téléchargement démarré!');
|
||||
selectElement.value = '';
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
alert('Erreur lors du démarrage du téléchargement');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle season download
|
||||
*/
|
||||
async function handleDownloadSeason(encodedUrl, lang) {
|
||||
const url = decodeURIComponent(encodedUrl);
|
||||
|
||||
if (!confirm(`⚠️ Attention: Vous allez télécharger toute la saison. Cela peut prendre du temps et utiliser beaucoup d'espace disque.\n\nVoulez-vous continuer ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await downloadSeason(url, lang);
|
||||
loadDownloads();
|
||||
alert(`✅ ${data.message}\n\n${data.total_episodes} épisodes ont été ajoutés à la file de téléchargement!`);
|
||||
} catch (error) {
|
||||
console.error('Season download error:', error);
|
||||
alert('Erreur lors du démarrage du téléchargement de la saison');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all seasons and episodes and display them
|
||||
*/
|
||||
async function loadAllSeasonsAndEpisodes(providerId, encodedUrl, lang) {
|
||||
const url = decodeURIComponent(encodedUrl);
|
||||
const cardId = `anime-${providerId}-${encodedUrl}`;
|
||||
const card = document.getElementById(cardId);
|
||||
|
||||
if (!card) {
|
||||
console.error('Card not found:', cardId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing all-seasons container if present
|
||||
const existingContainer = document.getElementById(`all-seasons-${providerId}-${encodedUrl}`);
|
||||
if (existingContainer) {
|
||||
existingContainer.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create container for all seasons
|
||||
const container = document.createElement('div');
|
||||
container.id = `all-seasons-${providerId}-${encodedUrl}`;
|
||||
container.style.cssText = 'margin-top: 16px;';
|
||||
|
||||
try {
|
||||
// Fetch all seasons
|
||||
const response = await fetch(`${API_BASE}/anime/seasons?url=${encodeURIComponent(url)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch seasons');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/anime/mal/search?q=2024&limit=12');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.seasons || data.seasons.length === 0) {
|
||||
container.innerHTML = '<div style="padding: 10px; color: #888;">Aucune saison disponible</div>';
|
||||
card.appendChild(container);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create HTML for all seasons
|
||||
let html = '<div style="margin-bottom: 12px;"><strong>Toutes les saisons</strong></div>';
|
||||
|
||||
for (const season of data.seasons) {
|
||||
const seasonId = `season-${encodeURIComponent(season.url)}`;
|
||||
|
||||
html += `
|
||||
<div class="season-block" style="margin-bottom: 12px; padding: 12px; background: rgba(255, 255, 255, 0.05); border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; color: #00d9ff;">${escapeHtml(season.title)}</div>
|
||||
<div style="font-size: 12px; color: #888;">${season.episode_count || '?'} épisodes</div>
|
||||
</div>
|
||||
<div id="${seasonId}-episodes" style="display: none;">
|
||||
<select class="episode-select" data-season-url="${escapeHtml(season.url)}" style="width: 100%; margin-bottom: 8px;">
|
||||
<option value="">Cliquez pour charger les épisodes...</option>
|
||||
</select>
|
||||
<div class="season-actions" style="display: none; gap: 8px;">
|
||||
<button class="btn-primary btn-small" onclick="downloadSeasonEpisode('${encodeURIComponent(season.url)}', '${providerId}', '${lang}')">
|
||||
📥 Télécharger
|
||||
</button>
|
||||
<button class="btn-secondary btn-small" onclick="downloadEntireSeason('${encodeURIComponent(season.url)}', '${lang}')" style="background: linear-gradient(45deg, #ff6b6b, #ffa500);">
|
||||
📦 Saison complète
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-secondary btn-small" onclick="toggleSeasonEpisodes('${seasonId}')" style="width: 100%;">
|
||||
▼ Afficher les épisodes
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
card.appendChild(container);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading all seasons:', error);
|
||||
container.innerHTML = '<div style="padding: 10px; color: #ff6b6b;">Erreur de chargement des saisons</div>';
|
||||
card.appendChild(container);
|
||||
}
|
||||
// Logic to render cards would go here, but for now we expect HTMX to handle core search
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle season episodes visibility
|
||||
*/
|
||||
function toggleSeasonEpisodes(seasonId) {
|
||||
const episodesDiv = document.getElementById(`${seasonId}-episodes`);
|
||||
const button = episodesDiv.parentElement.querySelector('button[onclick^="toggleSeasonEpisodes"]');
|
||||
|
||||
if (episodesDiv.style.display === 'none') {
|
||||
episodesDiv.style.display = 'block';
|
||||
button.textContent = '▲ Masquer les épisodes';
|
||||
|
||||
// Load episodes if not already loaded
|
||||
const select = episodesDiv.querySelector('.episode-select');
|
||||
if (select && select.options.length <= 1) {
|
||||
const seasonUrl = select.dataset.seasonUrl;
|
||||
loadSeasonEpisodes(seasonUrl, select);
|
||||
}
|
||||
} else {
|
||||
episodesDiv.style.display = 'none';
|
||||
button.textContent = '▼ Afficher les épisodes';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load episodes for a specific season
|
||||
*/
|
||||
async function loadSeasonEpisodes(seasonUrl, selectElement) {
|
||||
try {
|
||||
selectElement.innerHTML = '<option value="">Chargement...</option>';
|
||||
selectElement.disabled = true;
|
||||
|
||||
const data = await loadEpisodes(decodeURIComponent(seasonUrl), 'vostfr');
|
||||
|
||||
if (data.episodes && data.episodes.length > 0) {
|
||||
selectElement.innerHTML = '<option value="">Sélectionner un épisode</option>';
|
||||
data.episodes.forEach(ep => {
|
||||
const option = document.createElement('option');
|
||||
option.value = ep.url;
|
||||
option.textContent = `Épisode ${ep.episode}`;
|
||||
selectElement.appendChild(option);
|
||||
});
|
||||
selectElement.disabled = false;
|
||||
|
||||
// Show action buttons
|
||||
const actionsDiv = selectElement.parentElement.querySelector('.season-actions');
|
||||
if (actionsDiv) {
|
||||
actionsDiv.style.display = 'flex';
|
||||
}
|
||||
} else {
|
||||
selectElement.innerHTML = '<option value="">Aucun épisode disponible</option>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading episodes:', error);
|
||||
selectElement.innerHTML = '<option value="">Erreur de chargement</option>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download selected episode from season
|
||||
*/
|
||||
async function downloadSeasonEpisode(encodedSeasonUrl, providerId, lang) {
|
||||
const seasonUrl = decodeURIComponent(encodedSeasonUrl);
|
||||
const selectElement = document.querySelector(`[data-season-url="${seasonUrl}"]`);
|
||||
|
||||
if (!selectElement) {
|
||||
console.error('Select element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const episodeUrl = selectElement.value;
|
||||
if (!episodeUrl) {
|
||||
alert('Veuillez sélectionner un épisode');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadEpisode(episodeUrl);
|
||||
loadDownloads();
|
||||
alert('Téléchargement démarré!');
|
||||
selectElement.value = '';
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
alert('Erreur lors du démarrage du téléchargement');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download entire season
|
||||
*/
|
||||
async function downloadEntireSeason(encodedSeasonUrl, lang) {
|
||||
const seasonUrl = decodeURIComponent(encodedSeasonUrl);
|
||||
|
||||
if (!confirm(`⚠️ Attention: Vous allez télécharger toute cette saison. Cela peut prendre du temps et utiliser beaucoup d'espace disque.\n\nVoulez-vous continuer ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await downloadSeason(seasonUrl, lang);
|
||||
loadDownloads();
|
||||
alert(`✅ ${data.message}\n\n${data.total_episodes} épisodes ont été ajoutés à la file de téléchargement!`);
|
||||
} catch (error) {
|
||||
console.error('Season download error:', error);
|
||||
alert('Erreur lors du démarrage du téléchargement de la saison');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search form submission
|
||||
*/
|
||||
async function handleSearch() {
|
||||
const query = document.getElementById('searchInput').value.trim();
|
||||
|
||||
if (!query) return;
|
||||
|
||||
// Use the new anime details search
|
||||
await searchAnimeDetails(query);
|
||||
}
|
||||
|
||||
// Handle anime search (new dedicated function)
|
||||
async function handleAnimeSearch() {
|
||||
const searchInput = document.getElementById('animeSearchInput') || document.getElementById('searchInput');
|
||||
if (!searchInput) return;
|
||||
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
// Use the new anime details search
|
||||
await searchAnimeDetails(query);
|
||||
console.log('Legacy handleAnimeSearch - using HTMX form instead');
|
||||
}
|
||||
|
||||
// Ensure global scope
|
||||
window.handleSearch = handleSearch;
|
||||
window.loadAnimeReleases = loadAnimeReleases;
|
||||
window.handleAnimeSearch = handleAnimeSearch;
|
||||
|
||||
/**
|
||||
* Handle direct download form submission
|
||||
*/
|
||||
async function handleDirectDownload(e) {
|
||||
e.preventDefault();
|
||||
const url = document.getElementById('urlInput').value;
|
||||
|
||||
try {
|
||||
await startDownload(url);
|
||||
document.getElementById('urlInput').value = '';
|
||||
loadDownloads();
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
alert('Erreur lors du démarrage du téléchargement');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all functions are globally accessible
|
||||
window.displaySearchResults = displaySearchResults;
|
||||
window.renderAnimeCard = renderAnimeCard;
|
||||
window.renderAnimeMetadata = renderAnimeMetadata;
|
||||
window.loadSeasonsForAnime = loadSeasonsForAnime;
|
||||
window.handleSeasonChange = handleSeasonChange;
|
||||
window.loadEpisodesForAnime = loadEpisodesForAnime;
|
||||
window.handleDownloadEpisode = handleDownloadEpisode;
|
||||
window.handleDownloadSeason = handleDownloadSeason;
|
||||
window.handleSearch = handleSearch;
|
||||
window.handleDirectDownload = handleDirectDownload;
|
||||
window.loadAllSeasonsAndEpisodes = loadAllSeasonsAndEpisodes;
|
||||
window.toggleSeasonEpisodes = toggleSeasonEpisodes;
|
||||
window.loadSeasonEpisodes = loadSeasonEpisodes;
|
||||
window.downloadSeasonEpisode = downloadSeasonEpisode;
|
||||
window.downloadEntireSeason = downloadEntireSeason;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Auth API client module
|
||||
* Following the pattern from static/js/watchlist.js (global exports)
|
||||
*/
|
||||
|
||||
// Use the global API_BASE from auth-utils.js, fallback to /api
|
||||
const AUTH_API_BASE = typeof window.API_BASE !== 'undefined' ? window.API_BASE : '/api';
|
||||
|
||||
async function login(username, password) {
|
||||
try {
|
||||
const response = await fetch(`${AUTH_API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const data = window.safeJsonParse(text, {});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = data.detail || 'Erreur de connexion';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
throw new Error('Erreur de connexion au serveur');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function register(username, password, email = null, full_name = null) {
|
||||
try {
|
||||
const response = await fetch(`${AUTH_API_BASE}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, email, full_name }),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const data = window.safeJsonParse(text, {});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = data.detail || 'Erreur lors de l\'inscription';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
throw new Error('Erreur de connexion au serveur');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
const response = await fetch(`${AUTH_API_BASE}/auth/logout`, { method: 'POST' });
|
||||
const text = await response.text();
|
||||
const data = window.safeJsonParse(text, { status: 'success' });
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { status: 'success', message: 'Logged out locally' };
|
||||
}
|
||||
}
|
||||
|
||||
async function getMe(token) {
|
||||
try {
|
||||
const response = await fetch(`${AUTH_API_BASE}/auth/me`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const data = window.safeJsonParse(text, {});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = data.detail || 'Erreur de connexion';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
throw new Error('Erreur de connexion au serveur');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
window.authApi = {
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
getMe,
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Auth UI handlers module
|
||||
* Following the pattern from static/js/watchlist.js (global exports)
|
||||
*/
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const username = document.getElementById('loginUsername').value;
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
const button = document.getElementById('loginSubmit');
|
||||
|
||||
if (!button) {
|
||||
console.error('Login button not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = button.textContent;
|
||||
setLoading('loginSubmit', true, { loadingText: 'Connexion...', originalText });
|
||||
|
||||
try {
|
||||
const data = await window.authApi.login(username, password);
|
||||
|
||||
if (data.access_token) {
|
||||
window.setToken(data.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
window.displaySuccess('authSuccess', 'Connexion réussie! Redirection...');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/web';
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
window.displayError('authError', error.message || 'Erreur lors de la connexion');
|
||||
} finally {
|
||||
setLoading('loginSubmit', false, { originalText });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const username = document.getElementById('registerUsername').value;
|
||||
const password = document.getElementById('registerPassword').value;
|
||||
const passwordConfirm = document.getElementById('registerPasswordConfirm').value;
|
||||
const email = document.getElementById('registerEmail').value || null;
|
||||
const full_name = document.getElementById('registerFullName').value || null;
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
window.displayError('authError', 'Les mots de passe ne correspondent pas');
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.getElementById('registerSubmit');
|
||||
if (!button) {
|
||||
console.error('Register button not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = button.textContent;
|
||||
setLoading('registerSubmit', true, { loadingText: 'Inscription...', originalText });
|
||||
|
||||
try {
|
||||
const data = await window.authApi.register(username, password, email, full_name);
|
||||
|
||||
window.displaySuccess('authSuccess', 'Inscription réussie! Vous pouvez maintenant vous connecter.');
|
||||
|
||||
setTimeout(() => {
|
||||
window.authUi.switchTab('login');
|
||||
document.getElementById('loginUsername').value = username;
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
window.displayError('authError', error.message || 'Erreur lors de l\'inscription');
|
||||
} finally {
|
||||
setLoading('registerSubmit', false, { originalText });
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(buttonId, isLoading, options = {}) {
|
||||
const button = document.getElementById(buttonId);
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultLoadingText = '...';
|
||||
const loadingText = options.loadingText || defaultLoadingText;
|
||||
|
||||
if (isLoading) {
|
||||
const origText = options.originalText || button.textContent;
|
||||
button.dataset.originalText = origText;
|
||||
button.textContent = loadingText;
|
||||
button.disabled = true;
|
||||
} else {
|
||||
const origText = button.dataset.originalText || options.originalText || 'Se connecter';
|
||||
button.textContent = origText;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetLoading(buttonId, originalText) {
|
||||
setLoading(buttonId, false, { originalText });
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
const tabs = document.querySelectorAll('.auth-tab');
|
||||
const forms = document.querySelectorAll('.auth-form');
|
||||
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
forms.forEach(f => f.classList.remove('active'));
|
||||
|
||||
if (tab === 'login') {
|
||||
tabs[0].classList.add('active');
|
||||
document.getElementById('loginForm').classList.add('active');
|
||||
} else {
|
||||
tabs[1].classList.add('active');
|
||||
document.getElementById('registerForm').classList.add('active');
|
||||
}
|
||||
|
||||
document.getElementById('authError').classList.remove('show');
|
||||
document.getElementById('authSuccess').classList.remove('show');
|
||||
}
|
||||
|
||||
window.authUi = {
|
||||
handleLogin,
|
||||
handleRegister,
|
||||
setLoading,
|
||||
resetLoading,
|
||||
switchTab,
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Auth utilities - safe JSON parsing and error display
|
||||
* Following the pattern from static/js/watchlist.js (global exports)
|
||||
*/
|
||||
|
||||
// API base URL - use relative path for same-origin
|
||||
const API_BASE = '/api';
|
||||
|
||||
/**
|
||||
* Safely parse JSON string with fallback
|
||||
* @param {string} text - The JSON string to parse
|
||||
* @param {*} fallback - The fallback value if parsing fails (default: null)
|
||||
* @returns {*} Parsed object or fallback value
|
||||
*/
|
||||
function safeJsonParse(text, fallback = null) {
|
||||
try {
|
||||
if (text === undefined || text === null || text === '') {
|
||||
return fallback;
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
console.error('JSON parse error:', error.message);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display error message in the specified element
|
||||
* Handles string, object, and array errors properly
|
||||
* @param {string} elementId - The ID of the element to display error in
|
||||
* @param {*} error - The error (string, object, or array)
|
||||
* @param {string} defaultMessage - Default message if error is invalid
|
||||
*/
|
||||
function displayError(elementId, error, defaultMessage = 'Une erreur est survenue') {
|
||||
const errorDiv = document.getElementById(elementId);
|
||||
if (!errorDiv) {
|
||||
console.error('Error element not found:', elementId);
|
||||
return;
|
||||
}
|
||||
|
||||
let message = defaultMessage;
|
||||
|
||||
if (error === null || error === undefined) {
|
||||
message = defaultMessage;
|
||||
} else if (typeof error === 'string') {
|
||||
message = error;
|
||||
} else if (typeof error === 'object') {
|
||||
// Handle array errors
|
||||
if (Array.isArray(error)) {
|
||||
message = error.join('\n');
|
||||
}
|
||||
// Handle FastAPI HTTPException detail (can be string or object)
|
||||
else if (error.detail) {
|
||||
if (typeof error.detail === 'string') {
|
||||
message = error.detail;
|
||||
} else if (typeof error.detail === 'object' && error.detail.msg) {
|
||||
message = error.detail.msg;
|
||||
} else {
|
||||
// Stringify the object to avoid "[object Object]"
|
||||
message = JSON.stringify(error.detail);
|
||||
}
|
||||
}
|
||||
// Handle generic object
|
||||
else {
|
||||
message = JSON.stringify(error);
|
||||
}
|
||||
}
|
||||
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.classList.add('show');
|
||||
|
||||
// Hide success message if visible
|
||||
const successDiv = document.getElementById(elementId.replace('Error', 'Success'));
|
||||
if (successDiv) {
|
||||
successDiv.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display success message in the specified element
|
||||
* @param {string} elementId - The ID of the element to display success in
|
||||
* @param {string} message - The success message
|
||||
*/
|
||||
function displaySuccess(elementId, message) {
|
||||
const successDiv = document.getElementById(elementId);
|
||||
if (!successDiv) {
|
||||
console.error('Success element not found:', elementId);
|
||||
return;
|
||||
}
|
||||
|
||||
successDiv.textContent = message;
|
||||
successDiv.classList.add('show');
|
||||
|
||||
// Hide error message if visible
|
||||
const errorDiv = document.getElementById(elementId.replace('Success', 'Error'));
|
||||
if (errorDiv) {
|
||||
errorDiv.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
// Export globally (following watchlist.js pattern)
|
||||
window.safeJsonParse = safeJsonParse;
|
||||
window.displayError = displayError;
|
||||
window.displaySuccess = displaySuccess;
|
||||
window.API_BASE = API_BASE;
|
||||
+51
-77
@@ -5,116 +5,91 @@
|
||||
// Use relative path for API
|
||||
const AUTH_API_BASE = '/api';
|
||||
|
||||
const COOKIE_NAME = 'auth_token';
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
function setToken(token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + COOKIE_MAX_AGE * 1000);
|
||||
document.cookie = `${COOKIE_NAME}=${token};expires=${expires.toUTCString()};path=/;SameSite=Strict`;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
const cookieToken = getTokenFromCookie();
|
||||
if (cookieToken) return cookieToken;
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
|
||||
function getTokenFromCookie() {
|
||||
const name = COOKIE_NAME + '=';
|
||||
const decodedCookie = decodeURIComponent(document.cookie);
|
||||
const cookieArray = decodedCookie.split(';');
|
||||
for (let i = 0; i < cookieArray.length; i++) {
|
||||
let cookie = cookieArray[i];
|
||||
while (cookie.charAt(0) === ' ') cookie = cookie.substring(1);
|
||||
if (cookie.indexOf(name) === 0) return cookie.substring(name.length, cookie.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeToken() {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user');
|
||||
document.cookie = `${COOKIE_NAME}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`;
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
async function checkAuth() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const userStr = localStorage.getItem('user');
|
||||
const token = getToken();
|
||||
|
||||
if (!token) {
|
||||
// Redirect to login page instead of just showing prompt
|
||||
redirectToLogin();
|
||||
window.dispatchEvent(new CustomEvent('auth-logout'));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify token with server
|
||||
try {
|
||||
const response = await fetch(`${AUTH_API_BASE}/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showUserInfo(data.user);
|
||||
showMainContent();
|
||||
|
||||
window.dispatchEvent(new CustomEvent('auth-success', {
|
||||
detail: { username: data.user.full_name || data.user.username }
|
||||
}));
|
||||
|
||||
return true;
|
||||
} else {
|
||||
// Token invalid, remove it and redirect
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user');
|
||||
redirectToLogin();
|
||||
removeToken();
|
||||
window.dispatchEvent(new CustomEvent('auth-logout'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check error:', error);
|
||||
// On error, redirect to login
|
||||
redirectToLogin();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to login page
|
||||
function redirectToLogin() {
|
||||
// Only redirect if not already on login page
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
// Show user info when authenticated
|
||||
function showUserInfo(user) {
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
const loginPrompt = document.getElementById('loginPrompt');
|
||||
const mainTabs = document.getElementById('mainTabs');
|
||||
const currentUser = document.getElementById('currentUser');
|
||||
|
||||
if (userInfo) userInfo.style.display = 'flex';
|
||||
if (loginPrompt) loginPrompt.style.display = 'none';
|
||||
if (mainTabs) mainTabs.style.visibility = 'visible';
|
||||
if (currentUser) currentUser.textContent = user.full_name || user.username;
|
||||
}
|
||||
|
||||
// Show main content (only when authenticated)
|
||||
function showMainContent() {
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) mainContent.style.display = 'block';
|
||||
}
|
||||
|
||||
// Hide main content (when not authenticated)
|
||||
function hideMainContent() {
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) mainContent.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show login prompt when not authenticated (not used anymore - we redirect instead)
|
||||
function showLoginPrompt() {
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
const loginPrompt = document.getElementById('loginPrompt');
|
||||
const mainTabs = document.getElementById('mainTabs');
|
||||
|
||||
if (userInfo) userInfo.style.display = 'none';
|
||||
if (loginPrompt) loginPrompt.style.display = 'block';
|
||||
if (mainTabs) mainTabs.style.visibility = 'hidden';
|
||||
|
||||
// Hide main content
|
||||
hideMainContent();
|
||||
}
|
||||
|
||||
// Handle logout
|
||||
async function handleLogout() {
|
||||
if (!confirm('Êtes-vous sûr de vouloir vous déconnecter?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove token from localStorage
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user');
|
||||
|
||||
// Call logout endpoint
|
||||
if (!confirm('Êtes-vous sûr de vouloir vous déconnecter?')) return;
|
||||
removeToken();
|
||||
try {
|
||||
await fetch(`${AUTH_API_BASE}/auth/logout`, { method: 'POST' });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
|
||||
// Redirect to login page
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
// Add authorization header to all fetch requests
|
||||
function addAuthHeader(options = {}) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
options.headers = options.headers || {};
|
||||
options.headers['Authorization'] = `Bearer ${token}`;
|
||||
@@ -122,21 +97,20 @@ function addAuthHeader(options = {}) {
|
||||
return options;
|
||||
}
|
||||
|
||||
// Wrapper for fetch with auth
|
||||
async function authFetch(url, options = {}) {
|
||||
options = addAuthHeader(options);
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
// Make functions available globally
|
||||
// Global exposure
|
||||
window.checkAuth = checkAuth;
|
||||
window.showUserInfo = showUserInfo;
|
||||
window.showLoginPrompt = showLoginPrompt;
|
||||
window.handleLogout = handleLogout;
|
||||
window.authFetch = authFetch;
|
||||
window.addAuthHeader = addAuthHeader;
|
||||
window.getToken = getToken;
|
||||
window.setToken = setToken;
|
||||
window.removeToken = removeToken;
|
||||
|
||||
// Check authentication on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
+11
-394
@@ -1,401 +1,18 @@
|
||||
// Download state
|
||||
let allDownloads = [];
|
||||
let collapsedGroups = new Set();
|
||||
let isClearing = false;
|
||||
|
||||
/**
|
||||
* Load all downloads
|
||||
* Downloads management (Legacy - Modernized to HTMX)
|
||||
* This file is kept for backward compatibility but internal polling is disabled.
|
||||
*/
|
||||
|
||||
async function loadDownloads() {
|
||||
// Skip refresh if currently clearing downloads to avoid conflicts
|
||||
if (isClearing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getDownloads();
|
||||
allDownloads = data.downloads;
|
||||
updateStats();
|
||||
filterDownloads();
|
||||
} catch (error) {
|
||||
console.error('Failed to load downloads:', error);
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.trigger('#downloads-container-inner', 'refresh');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update download statistics display
|
||||
*/
|
||||
function updateStats() {
|
||||
const stats = {
|
||||
total: allDownloads.length,
|
||||
downloading: allDownloads.filter(d => d.status === 'downloading').length,
|
||||
paused: allDownloads.filter(d => d.status === 'paused').length,
|
||||
completed: allDownloads.filter(d => d.status === 'completed').length,
|
||||
cancelled: allDownloads.filter(d => d.status === 'cancelled').length,
|
||||
failed: allDownloads.filter(d => d.status === 'failed').length
|
||||
};
|
||||
|
||||
const statsHtml = `
|
||||
<div class="stat-item">Total: <span class="stat-count">${stats.total}</span></div>
|
||||
${stats.downloading > 0 ? `<div class="stat-item">En cours: <span class="stat-count" style="color: #00d9ff;">${stats.downloading}</span></div>` : ''}
|
||||
${stats.paused > 0 ? `<div class="stat-item">En pause: <span class="stat-count" style="color: #ffa500;">${stats.paused}</span></div>` : ''}
|
||||
${stats.completed > 0 ? `<div class="stat-item">Terminés: <span class="stat-count" style="color: #00ff88;">${stats.completed}</span></div>` : ''}
|
||||
${stats.cancelled > 0 ? `<div class="stat-item">Annulés: <span class="stat-count" style="color: #ff6b6b;">${stats.cancelled}</span></div>` : ''}
|
||||
${stats.failed > 0 ? `<div class="stat-item">Échoués: <span class="stat-count" style="color: #ff4444;">${stats.failed}</span></div>` : ''}
|
||||
`;
|
||||
|
||||
document.getElementById('downloadsStats').innerHTML = statsHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and sort downloads
|
||||
*/
|
||||
function filterDownloads() {
|
||||
const statusFilter = document.getElementById('statusFilter').value;
|
||||
const sortBy = document.getElementById('sortBy').value;
|
||||
const groupBy = document.getElementById('groupBy').value;
|
||||
const searchTerm = document.getElementById('searchDownloads').value.toLowerCase();
|
||||
|
||||
// Filter by status and search
|
||||
let filtered = allDownloads.filter(dl => {
|
||||
const matchesStatus = statusFilter === 'all' || dl.status === statusFilter;
|
||||
const matchesSearch = !searchTerm ||
|
||||
dl.filename.toLowerCase().includes(searchTerm) ||
|
||||
(dl.url && dl.url.toLowerCase().includes(searchTerm));
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'date_asc':
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
case 'name':
|
||||
return a.filename.localeCompare(b.filename);
|
||||
case 'name_desc':
|
||||
return b.filename.localeCompare(a.filename);
|
||||
case 'size':
|
||||
return (b.total_bytes || 0) - (a.total_bytes || 0);
|
||||
case 'date':
|
||||
default:
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply grouping
|
||||
displayDownloads(filtered, groupBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group downloads by criteria
|
||||
*/
|
||||
function groupDownloads(downloads, groupBy) {
|
||||
const groups = {};
|
||||
|
||||
downloads.forEach(dl => {
|
||||
let key = 'Ungrouped';
|
||||
|
||||
switch (groupBy) {
|
||||
case 'series':
|
||||
key = extractSeriesName(dl.filename);
|
||||
break;
|
||||
case 'status':
|
||||
key = translateStatus(dl.status);
|
||||
break;
|
||||
case 'day':
|
||||
key = getDayString(dl.created_at);
|
||||
break;
|
||||
default:
|
||||
key = 'Tous';
|
||||
}
|
||||
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(dl);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display downloads (flat or grouped)
|
||||
*/
|
||||
function displayDownloads(downloads, groupBy = 'none') {
|
||||
const container = document.getElementById('downloadsList');
|
||||
|
||||
if (downloads.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path>
|
||||
</svg>
|
||||
<p>Aucun téléchargement trouvé</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
// Disable legacy intervals
|
||||
window.loadDownloads = loadDownloads;
|
||||
window.handleCleanupDownloads = () => {
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.ajax('POST', '/api/downloads/cleanup', { swap: 'none' });
|
||||
}
|
||||
|
||||
// Group downloads if needed
|
||||
if (groupBy && groupBy !== 'none') {
|
||||
const groups = groupDownloads(downloads, groupBy);
|
||||
const groupNames = Object.keys(groups);
|
||||
|
||||
// Sort group names
|
||||
groupNames.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
// Display grouped downloads
|
||||
let html = '';
|
||||
groupNames.forEach((groupName, index) => {
|
||||
const groupDownloads = groups[groupName];
|
||||
const groupId = `group-${index}`;
|
||||
const isCollapsed = collapsedGroups.has(groupId);
|
||||
const collapsedClass = isCollapsed ? 'collapsed' : '';
|
||||
const displayStyle = isCollapsed ? 'display: none;' : '';
|
||||
|
||||
html += `
|
||||
<div class="downloads-group">
|
||||
<div class="downloads-group-header ${collapsedClass}" onclick="toggleGroup('${groupId}')">
|
||||
<div class="downloads-group-title">${escapeHtml(groupName)}</div>
|
||||
<div class="downloads-group-count">${groupDownloads.length}</div>
|
||||
</div>
|
||||
<div class="downloads-group-items" id="${groupId}" style="${displayStyle}">
|
||||
${groupDownloads.map(dl => renderDownloadItem(dl)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
} else {
|
||||
// Display flat list
|
||||
container.innerHTML = downloads.map(dl => renderDownloadItem(dl)).join('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single download item
|
||||
*/
|
||||
function renderDownloadItem(dl) {
|
||||
return `
|
||||
<div class="download-item">
|
||||
<div class="download-header">
|
||||
<div class="filename">${escapeHtml(dl.filename)}</div>
|
||||
<span class="status status-${dl.status}">${translateStatus(dl.status)}</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${dl.progress}%"></div>
|
||||
</div>
|
||||
<div class="download-info">
|
||||
<span>${formatBytes(dl.downloaded_bytes)}${dl.total_bytes ? ' / ' + formatBytes(dl.total_bytes) : ''}</span>
|
||||
<span>${dl.speed > 0 ? formatSpeed(dl.speed) : ''}</span>
|
||||
</div>
|
||||
<div class="download-actions">
|
||||
${renderDownloadActions(dl)}
|
||||
</div>
|
||||
${dl.error ? `<div class="error-message">${escapeHtml(dl.error)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render download action buttons based on status
|
||||
*/
|
||||
function renderDownloadActions(dl) {
|
||||
switch (dl.status) {
|
||||
case 'downloading':
|
||||
return `
|
||||
<button class="btn-small btn-pause" onclick="handlePause('${dl.id}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Pause
|
||||
</button>
|
||||
<button class="btn-small btn-cancel" onclick="handleCancel('${dl.id}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
Annuler
|
||||
</button>
|
||||
`;
|
||||
|
||||
case 'paused':
|
||||
return `
|
||||
<button class="btn-small btn-resume" onclick="handleResume('${dl.id}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Reprendre
|
||||
</button>
|
||||
<button class="btn-small btn-cancel" onclick="handleCancel('${dl.id}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
Annuler
|
||||
</button>
|
||||
`;
|
||||
|
||||
case 'completed':
|
||||
return `
|
||||
<button class="btn-small btn-download" style="background: linear-gradient(45deg, #ff6b6b, #ffa500);" onclick="watchVideo('${dl.id}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Regarder
|
||||
</button>
|
||||
<button class="btn-small btn-download" onclick="downloadFile('${dl.id}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Télécharger
|
||||
</button>
|
||||
<button class="btn-small btn-cancel" onclick="handleCancel('${dl.id}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
Supprimer
|
||||
</button>
|
||||
`;
|
||||
|
||||
case 'failed':
|
||||
default:
|
||||
return `
|
||||
<button class="btn-small btn-cancel" onclick="handleCancel('${dl.id}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
Supprimer
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle group collapse/expand
|
||||
*/
|
||||
function toggleGroup(groupId) {
|
||||
const items = document.getElementById(groupId);
|
||||
const header = items.previousElementSibling;
|
||||
|
||||
if (!items || !header) {
|
||||
console.error('Could not find group elements');
|
||||
return;
|
||||
}
|
||||
|
||||
const isCollapsed = collapsedGroups.has(groupId);
|
||||
|
||||
if (isCollapsed) {
|
||||
items.style.display = 'flex';
|
||||
header.classList.remove('collapsed');
|
||||
collapsedGroups.delete(groupId);
|
||||
} else {
|
||||
items.style.display = 'none';
|
||||
header.classList.add('collapsed');
|
||||
collapsedGroups.add(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pause button click
|
||||
*/
|
||||
async function handlePause(id) {
|
||||
try {
|
||||
await pauseDownload(id);
|
||||
loadDownloads();
|
||||
} catch (error) {
|
||||
console.error('Pause error:', error);
|
||||
alert('Erreur lors de la mise en pause');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resume button click
|
||||
*/
|
||||
async function handleResume(id) {
|
||||
try {
|
||||
await resumeDownload(id);
|
||||
loadDownloads();
|
||||
} catch (error) {
|
||||
console.error('Resume error:', error);
|
||||
alert('Erreur lors de la reprise');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cancel/delete button click
|
||||
*/
|
||||
async function handleCancel(id) {
|
||||
if (!confirm('Êtes-vous sûr ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cancelDownload(id);
|
||||
loadDownloads();
|
||||
} catch (error) {
|
||||
console.error('Cancel error:', error);
|
||||
alert('Erreur lors de la suppression');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear unwanted downloads
|
||||
*/
|
||||
async function clearCompleted() {
|
||||
const unwanted = allDownloads.filter(dl =>
|
||||
dl.status === 'cancelled' ||
|
||||
dl.status === 'failed' ||
|
||||
dl.status === 'deleted'
|
||||
);
|
||||
|
||||
if (unwanted.length === 0) {
|
||||
alert('Aucun téléchargement à supprimer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Count by status
|
||||
const byStatus = unwanted.reduce((acc, dl) => {
|
||||
acc[dl.status] = (acc[dl.status] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let message = 'Supprimer ';
|
||||
if (byStatus.cancelled) message += `${byStatus.cancelled} annulé(s) `;
|
||||
if (byStatus.failed) message += `${byStatus.failed} échoué(s) `;
|
||||
if (byStatus.deleted) message += `${byStatus.deleted} supprimé(s) `;
|
||||
message += '?';
|
||||
|
||||
if (!confirm(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set flag to prevent auto-refresh conflicts
|
||||
isClearing = true;
|
||||
|
||||
try {
|
||||
// Delete all in parallel (much faster)
|
||||
await Promise.all(unwanted.map(dl => cancelDownload(dl.id)));
|
||||
} catch (error) {
|
||||
console.error('Error deleting downloads:', error);
|
||||
alert('Erreur lors de la suppression');
|
||||
} finally {
|
||||
// Clear flag and refresh
|
||||
isClearing = false;
|
||||
loadDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file to user's computer
|
||||
*/
|
||||
function downloadFile(id) {
|
||||
window.open(`${API_BASE}/download/${id}/file`, '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch video in player
|
||||
*/
|
||||
function watchVideo(id) {
|
||||
window.open(`/player/${id}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
+20
-249
@@ -1,265 +1,35 @@
|
||||
/**
|
||||
* Main initialization and event handlers
|
||||
* Main initialization and event handlers - Modernized for HTMX/Alpine
|
||||
*/
|
||||
|
||||
// Initialize on DOM load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeForms();
|
||||
loadProviders();
|
||||
loadDownloads();
|
||||
setInterval(loadDownloads, 1000);
|
||||
|
||||
// Load home content (recommendations & releases)
|
||||
loadHomeContent();
|
||||
// Only keeping essential initializations
|
||||
// Note: loadHomeContent() removed as it is now handled by hx-trigger="load"
|
||||
|
||||
// Initial download load
|
||||
if (typeof loadDownloads === 'function') {
|
||||
loadDownloads();
|
||||
setInterval(loadDownloads, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize form event listeners
|
||||
*/
|
||||
function initializeForms() {
|
||||
// Anime search form
|
||||
const animeSearchInput = document.getElementById('animeSearchInput');
|
||||
if (animeSearchInput) {
|
||||
animeSearchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAnimeSearch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Series search form
|
||||
const seriesSearchInput = document.getElementById('seriesSearchInput');
|
||||
if (seriesSearchInput) {
|
||||
seriesSearchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSeriesSearch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Direct download form
|
||||
const downloadForm = document.getElementById('downloadForm');
|
||||
if (downloadForm) {
|
||||
downloadForm.addEventListener('submit', handleDirectDownload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load providers dynamically (legacy support)
|
||||
* Note: This is kept for compatibility but the new interface uses static tabs
|
||||
*/
|
||||
async function loadProviders() {
|
||||
try {
|
||||
const data = await getProvidersInfo();
|
||||
|
||||
// Update supported hosts badges (if element exists)
|
||||
const hostsContainer = document.querySelector('.supported-hosts');
|
||||
if (hostsContainer) {
|
||||
hostsContainer.innerHTML = '';
|
||||
|
||||
Object.values(data.file_hosts).forEach(host => {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'host-badge';
|
||||
badge.textContent = `${host.icon} ${host.name}`;
|
||||
hostsContainer.appendChild(badge);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading providers:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create anime provider tab content
|
||||
*/
|
||||
function createAnimeTabContent(providerId, provider) {
|
||||
return `
|
||||
<div class="url-form">
|
||||
<div class="anime-input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="${providerId}UrlInput"
|
||||
placeholder="URL de l'anime (ex: ${provider.url_pattern})"
|
||||
>
|
||||
<button type="button" class="btn-primary" onclick="handleLoadProviderEpisodes('${providerId}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
Charger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="${providerId}EpisodeSelector" class="anime-select-group" style="display:none;">
|
||||
<select id="${providerId}EpisodeSelect">
|
||||
<option value="">Sélectionner un épisode</option>
|
||||
</select>
|
||||
<button type="button" class="btn-primary" onclick="handleDownloadProviderEpisode('${providerId}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create series provider tab content
|
||||
*/
|
||||
function createSeriesTabContent(providerId, provider) {
|
||||
return `
|
||||
<div class="url-form">
|
||||
<div class="anime-input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="${providerId}UrlInput"
|
||||
placeholder="URL de la série (ex: ${provider.url_pattern})"
|
||||
>
|
||||
<button type="button" class="btn-primary" onclick="handleLoadProviderEpisodes('${providerId}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
Charger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="${providerId}EpisodeSelector" class="anime-select-group" style="display:none;">
|
||||
<select id="${providerId}EpisodeSelect">
|
||||
<option value="">Sélectionner un épisode</option>
|
||||
</select>
|
||||
<button type="button" class="btn-primary" onclick="handleDownloadProviderEpisode('${providerId}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle load provider episodes
|
||||
*/
|
||||
async function handleLoadProviderEpisodes(providerId) {
|
||||
const animeUrl = document.getElementById(`${providerId}UrlInput`).value;
|
||||
if (!animeUrl) {
|
||||
alert('Veuillez entrer une URL d\'anime');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await loadEpisodes(animeUrl, null);
|
||||
|
||||
if (data.episodes && data.episodes.length > 0) {
|
||||
const select = document.getElementById(`${providerId}EpisodeSelect`);
|
||||
select.innerHTML = '<option value="">Sélectionner un épisode</option>';
|
||||
|
||||
data.episodes.forEach(ep => {
|
||||
const option = document.createElement('option');
|
||||
option.value = ep.url;
|
||||
option.textContent = `Épisode ${ep.episode}`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
document.getElementById(`${providerId}EpisodeSelector`).style.display = 'flex';
|
||||
} else {
|
||||
alert('Aucun épisode trouvé');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading episodes:', error);
|
||||
alert('Erreur lors du chargement des épisodes');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle download provider episode
|
||||
*/
|
||||
async function handleDownloadProviderEpisode(providerId) {
|
||||
const episodeUrl = document.getElementById(`${providerId}EpisodeSelect`).value;
|
||||
if (!episodeUrl) {
|
||||
alert('Veuillez sélectionner un épisode');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadEpisode(episodeUrl);
|
||||
document.getElementById(`${providerId}EpisodeSelect`).value = '';
|
||||
loadDownloads();
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
alert('Erreur lors du téléchargement');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch between tabs
|
||||
* Switch between tabs (Modernized to Alpine.js)
|
||||
*/
|
||||
function switchTab(tabName) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.tab').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
const tabElement = document.getElementById(`tab-${tabName}`);
|
||||
if (tabElement) {
|
||||
tabElement.classList.add('active');
|
||||
}
|
||||
|
||||
// Find and activate the button
|
||||
const buttons = document.querySelectorAll('.tab');
|
||||
buttons.forEach(btn => {
|
||||
const tabType = btn.getAttribute('data-tab-type');
|
||||
|
||||
if (tabType === 'home' && tabName === 'home') {
|
||||
btn.classList.add('active');
|
||||
} else if (tabType === 'anime' && tabName === 'anime') {
|
||||
// Static anime tab
|
||||
btn.classList.add('active');
|
||||
} else if (tabType === 'series' && tabName === 'series') {
|
||||
// Static series tab
|
||||
btn.classList.add('active');
|
||||
} else if (tabType === 'providers' && tabName === 'providers') {
|
||||
// Static providers tab
|
||||
btn.classList.add('active');
|
||||
} else if (tabType === 'anime' && btn.getAttribute('data-provider') === tabName.replace('anime-', '')) {
|
||||
btn.classList.add('active');
|
||||
} else if (tabType === 'series' && btn.getAttribute('data-provider') === tabName.replace('series-', '')) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Load home content when switching to home tab
|
||||
if (tabName === 'home') {
|
||||
// Content is already loaded on init, but you can reload if needed
|
||||
if (typeof loadHomeContent === 'function' && !document.getElementById('recommendationsList').hasChildNodes()) {
|
||||
loadHomeContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Load watchlist content when switching to watchlist tab
|
||||
if (tabName === 'watchlist') {
|
||||
if (typeof loadSchedulerStatus === 'function') {
|
||||
loadSchedulerStatus();
|
||||
}
|
||||
if (typeof displayWatchlist === 'function') {
|
||||
displayWatchlist();
|
||||
}
|
||||
}
|
||||
console.log('Switching tab to:', tabName);
|
||||
window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: tabName } }));
|
||||
window.location.hash = tabName;
|
||||
}
|
||||
|
||||
|
||||
// Handle URL hash on page load
|
||||
if (window.location.hash) {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash === 'watchlist' || hash === 'anime' || hash === 'series' || hash === 'providers') {
|
||||
switchTab(hash);
|
||||
const validTabs = ['home', 'watchlist', 'anime', 'series', 'providers', 'downloads', 'settings'];
|
||||
if (validTabs.includes(hash)) {
|
||||
// Short delay to ensure Alpine is ready
|
||||
setTimeout(() => switchTab(hash), 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,8 +37,9 @@ if (window.location.hash) {
|
||||
window.addEventListener('hashchange', function() {
|
||||
if (window.location.hash) {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash === 'watchlist' || hash === 'anime' || hash === 'series' || hash === 'providers') {
|
||||
const validTabs = ['home', 'watchlist', 'anime', 'series', 'providers', 'downloads', 'settings'];
|
||||
if (validTabs.includes(hash)) {
|
||||
switchTab(hash);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ async function loadRecommendations() {
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||
Soit l'API MyAnimeList est inaccessible, soit vous n'avez pas encore de téléchargements.
|
||||
</p>
|
||||
<button class="btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
|
||||
<button class="btn btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
</button>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@ async function loadRecommendations() {
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors du chargement des recommandations.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
<button class="btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
|
||||
<button class="btn btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
</button>
|
||||
</div>
|
||||
@@ -75,7 +75,7 @@ async function loadLatestReleases() {
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||
L'API MyAnimeList pourrait être temporairement inaccessible.
|
||||
</p>
|
||||
<button class="btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
|
||||
<button class="btn btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
</button>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@ async function loadLatestReleases() {
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors du chargement des sorties.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
<button class="btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
|
||||
<button class="btn btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
</button>
|
||||
</div>
|
||||
@@ -180,10 +180,10 @@ function renderRecommendationCard(anime) {
|
||||
` : ''}
|
||||
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||
🔗 MAL
|
||||
</button>
|
||||
<button class="btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
<button class="btn btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
📥 Télécharger
|
||||
</button>
|
||||
</div>
|
||||
@@ -233,10 +233,10 @@ function renderReleaseCard(anime) {
|
||||
` : ''}
|
||||
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||
🔗 MAL
|
||||
</button>
|
||||
<button class="btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
<button class="btn btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
📥 Télécharger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -54,10 +54,10 @@ async function handleSeriesSearch() {
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(s.url)}', '_blank')">
|
||||
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(s.url)}', '_blank')">
|
||||
🔗 Voir sur FS7
|
||||
</button>
|
||||
<button class="btn-primary btn-small" onclick="loadSeriesEpisodesDirect('${escapeHtml(s.url)}', '${escapeHtml(s.title)}')">
|
||||
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodesDirect('${escapeHtml(s.url)}', '${escapeHtml(s.title)}')">
|
||||
📥 Voir les épisodes
|
||||
</button>
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@ async function loadSeriesEpisodesDirect(url, title) {
|
||||
<option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
<button class="btn-primary" style="margin-top: 10px; width: 100%;" onclick="downloadSeriesEpisode('${escapeHtml(url)}', '${escapeHtml(title)}')">
|
||||
<button class="btn btn-primary" style="margin-top: 10px; width: 100%;" onclick="downloadSeriesEpisode('${escapeHtml(url)}', '${escapeHtml(title)}')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
|
||||
+9
-9
@@ -36,10 +36,10 @@ function renderSeriesRecommendationCard(series) {
|
||||
</div>
|
||||
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||
🔗 Voir sur FS7
|
||||
</button>
|
||||
<button class="btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||
📥 Voir les épisodes
|
||||
</button>
|
||||
</div>
|
||||
@@ -98,10 +98,10 @@ function renderSeriesReleaseCard(series) {
|
||||
</div>
|
||||
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||
🔗 Voir sur FS7
|
||||
</button>
|
||||
<button class="btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||
📥 Voir les épisodes
|
||||
</button>
|
||||
</div>
|
||||
@@ -238,7 +238,7 @@ async function loadSeriesReleases() {
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors du chargement des séries</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
<button class="btn-secondary btn-small" onclick="loadSeriesReleases()" style="margin-top: 10px;">
|
||||
<button class="btn btn-secondary btn-small" onclick="loadSeriesReleases()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
</button>
|
||||
</div>`;
|
||||
@@ -280,11 +280,11 @@ async function loadProvidersGrid() {
|
||||
` : ''}
|
||||
<div class="anime-card-actions">
|
||||
${domains.length > 0 ? `
|
||||
<button class="btn-primary btn-small" onclick="window.open('https://${domains[0]}', '_blank')">
|
||||
<button class="btn btn-primary btn-small" onclick="window.open('https://${domains[0]}', '_blank')">
|
||||
🔗 Visiter le site
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn-secondary btn-small" onclick="showProviderSearch('${id}')">
|
||||
<button class="btn btn-secondary btn-small" onclick="showProviderSearch('${id}')">
|
||||
🔍 Rechercher
|
||||
</button>
|
||||
</div>
|
||||
@@ -310,7 +310,7 @@ async function loadProvidersGrid() {
|
||||
<div class="anime-card-title">${host.icon} ${host.name}</div>
|
||||
</div>
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="showDownloadInfo()">
|
||||
<button class="btn btn-secondary btn-small" onclick="showDownloadInfo()">
|
||||
📥 Télécharger un fichier
|
||||
</button>
|
||||
</div>
|
||||
@@ -332,7 +332,7 @@ async function loadProvidersGrid() {
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors du chargement des fournisseurs</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
<button class="btn-secondary btn-small" onclick="loadProvidersGrid()" style="margin-top: 10px;">
|
||||
<button class="btn btn-secondary btn-small" onclick="loadProvidersGrid()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+11
-564
@@ -1,571 +1,18 @@
|
||||
/**
|
||||
* Watchlist UI functions
|
||||
* Watchlist UI (Legacy - Modernized to HTMX)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display watchlist items
|
||||
*/
|
||||
async function displayWatchlist(status = null) {
|
||||
const container = document.getElementById('watchlistContainer');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
container.innerHTML = '<div class="watchlist-loading">Chargement de la watchlist...</div>';
|
||||
|
||||
const items = await getWatchlist(status);
|
||||
const stats = await getWatchlistStats();
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-watchlist">
|
||||
<div style="text-align: center; padding: 60px 20px;">
|
||||
<svg style="width:80px;height:80px;margin:0 auto 20px;opacity:0.3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
<h3 style="color: #666; margin-bottom: 10px;">Aucun anime dans votre watchlist</h3>
|
||||
<p style="color: #999;">Ajoutez des animes depuis la recherche pour commencer le suivi automatique</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render stats
|
||||
let statsHtml = '';
|
||||
if (stats && stats.total > 0) {
|
||||
statsHtml = `
|
||||
<div class="watchlist-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin-bottom: 30px;">
|
||||
<div class="stat-card" style="background: rgba(0, 217, 255, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #00d9ff;">${stats.total}</div>
|
||||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">Total</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: rgba(76, 175, 80, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #4caf50;">${stats.active}</div>
|
||||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">Actifs</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: rgba(255, 152, 0, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #ff9800;">${stats.paused}</div>
|
||||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">En pause</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: rgba(158, 158, 158, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #9e9e9e;">${stats.completed}</div>
|
||||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">Terminés</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render items
|
||||
let itemsHtml = '';
|
||||
items.forEach(item => {
|
||||
const statusIcon = getStatusIcon(item.status);
|
||||
const statusBadge = getStatusBadge(item.status);
|
||||
const lastEpInfo = item.last_episode_downloaded > 0
|
||||
? `<span style="color: #999;">Dernier épisode: ${item.last_episode_downloaded}</span>`
|
||||
: '';
|
||||
|
||||
itemsHtml += `
|
||||
<div class="watchlist-item" id="watchlist-${item.id}" style="background: rgba(255,255,255,0.05); border-radius: 12px; padding: 20px; margin-bottom: 15px; border: 1px solid rgba(255,255,255,0.1);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; gap: 20px;">
|
||||
<div style="flex: 1;">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
||||
<h3 style="color: #fff; margin: 0; font-size: 18px;">${escapeHtml(item.anime_title)}</h3>
|
||||
${statusBadge}
|
||||
</div>
|
||||
|
||||
<div style="font-size: 13px; color: #999; margin-bottom: 8px;">
|
||||
${statusIcon} ${item.provider_id} • ${item.lang.toUpperCase()}
|
||||
</div>
|
||||
|
||||
${lastEpInfo ? `
|
||||
<div style="font-size: 12px; color: #999; margin-bottom: 8px;">
|
||||
${lastEpInfo}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${item.last_checked ? `
|
||||
<div style="font-size: 12px; color: #666;">
|
||||
Dernière vérification: ${new Date(item.last_checked).toLocaleString('fr-FR')}
|
||||
</div>
|
||||
` : '<div style="font-size: 12px; color: #666;">Jamais vérifié</div>'}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
${item.status === 'active' && item.auto_download ? `
|
||||
<button class="btn-secondary btn-small" onclick="handlePauseWatchlist('${item.id}')" style="padding: 6px 12px; font-size: 12px;" title="Mettre en pause">
|
||||
⏸️ Pause
|
||||
</button>
|
||||
` : item.status === 'paused' ? `
|
||||
<button class="btn-primary btn-small" onclick="handleResumeWatchlist('${item.id}')" style="padding: 6px 12px; font-size: 12px;" title="Reprendre">
|
||||
▶️ Reprendre
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
<button class="btn-secondary btn-small" onclick="handleCheckItem.call(this, '${item.id}')" style="padding: 6px 12px; font-size: 12px;" title="Vérifier maintenant">
|
||||
🔍 Vérifier
|
||||
</button>
|
||||
|
||||
<button class="btn-secondary btn-small" onclick="handleDeleteWatchlist('${item.id}')" style="padding: 6px 12px; font-size: 12px; background: rgba(244, 67, 54, 0.2); border-color: rgba(244, 67, 54, 0.5);" title="Supprimer">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${item.synopsis ? `
|
||||
<details style="margin-top: 15px;">
|
||||
<summary style="cursor: pointer; color: #999; font-size: 13px; padding: 5px 0;">📖 Synopsis</summary>
|
||||
<p style="color: #ccc; font-size: 13px; line-height: 1.5; margin-top: 10px;">${escapeHtml(item.synopsis)}</p>
|
||||
</details>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = statsHtml + itemsHtml;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading watchlist:', error);
|
||||
container.innerHTML = `
|
||||
<div class="error-message" style="text-align: center; padding: 40px; color: #f44;">
|
||||
❌ Erreur lors du chargement: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
async function displayWatchlist() {
|
||||
console.log('Legacy displayWatchlist called - redirected to HTMX');
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.trigger('#watchlist-items-container', 'load');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon
|
||||
*/
|
||||
function getStatusIcon(status) {
|
||||
const icons = {
|
||||
'active': '✅',
|
||||
'paused': '⏸️',
|
||||
'completed': '✨',
|
||||
'archived': '📦'
|
||||
};
|
||||
return icons[status] || '📌';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge
|
||||
*/
|
||||
function getStatusBadge(status) {
|
||||
const badges = {
|
||||
'active': '<span style="background: rgba(76, 175, 80, 0.2); color: #4caf50; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">Actif</span>',
|
||||
'paused': '<span style="background: rgba(255, 152, 0, 0.2); color: #ff9800; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">En pause</span>',
|
||||
'completed': '<span style="background: rgba(158, 158, 158, 0.2); color: #9e9e9e; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">Terminé</span>',
|
||||
'archived': '<span style="background: rgba(33, 150, 243, 0.2); color: #2196f3; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">Archivé</span>'
|
||||
};
|
||||
return badges[status] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add anime to watchlist from search results
|
||||
*/
|
||||
async function handleAddToWatchlist(animeUrl, providerId) {
|
||||
try {
|
||||
// Decode URL if it's encoded - always work with decoded URL
|
||||
let decodedUrl = animeUrl;
|
||||
try {
|
||||
decodedUrl = decodeURIComponent(animeUrl);
|
||||
} catch (e) {
|
||||
// URL might already be decoded
|
||||
}
|
||||
|
||||
// Get anime details from the DOM or API
|
||||
const response = await fetch(`${API_BASE}/anime/metadata?url=${encodeURIComponent(decodedUrl)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch anime details');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const metadata = data.metadata || {};
|
||||
|
||||
// Extract anime title from URL if not in metadata
|
||||
let animeTitle = metadata.title || 'Unknown Anime';
|
||||
if (animeTitle === 'Unknown Anime' || !animeTitle) {
|
||||
// Try to extract title from URL
|
||||
try {
|
||||
const urlParts = decodedUrl.split('/');
|
||||
// Find the anime name (usually between /catalogue/ and /saison/ or /vostfr/)
|
||||
const catalogueIndex = urlParts.indexOf('catalogue');
|
||||
if (catalogueIndex >= 0 && urlParts[catalogueIndex + 1]) {
|
||||
animeTitle = urlParts[catalogueIndex + 1];
|
||||
} else {
|
||||
// Fallback: use last part
|
||||
animeTitle = urlParts[urlParts.length - 2] || urlParts[urlParts.length - 1];
|
||||
}
|
||||
animeTitle = animeTitle.replace(/-/g, ' ').replace(/\+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
// Capitalize words
|
||||
animeTitle = animeTitle.replace(/\b\w/g, l => l.toUpperCase());
|
||||
} catch (e) {
|
||||
console.warn('Could not extract title from URL:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize provider_id to use dash format (anime-sama not animesama)
|
||||
let normalizedProviderId = providerId;
|
||||
if (providerId === 'animesama') {
|
||||
normalizedProviderId = 'anime-sama';
|
||||
}
|
||||
|
||||
const itemData = {
|
||||
anime_title: animeTitle,
|
||||
anime_url: decodedUrl, // Always use decoded URL
|
||||
provider_id: normalizedProviderId,
|
||||
lang: 'vostfr',
|
||||
auto_download: true,
|
||||
quality_preference: 'auto',
|
||||
poster_image: metadata.poster_image || null,
|
||||
cover_image: metadata.cover_image || null,
|
||||
synopsis: metadata.synopsis || null,
|
||||
genres: metadata.genres || []
|
||||
};
|
||||
|
||||
const result = await addToWatchlist(itemData);
|
||||
|
||||
// Trigger download of all episodes immediately
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const downloadResponse = await fetch(`${API_BASE}/watchlist/${result.id}/download-all`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (downloadResponse.ok) {
|
||||
const downloadResult = await downloadResponse.json();
|
||||
alert(`✅ "${result.anime_title}" a été ajouté et le téléchargement de tous les épisodes a commencé!\n\nVous recevrez automatiquement les nouveaux épisodes.`);
|
||||
} else {
|
||||
// Still show success even if download failed
|
||||
alert(`✅ "${result.anime_title}" a été ajouté à votre watchlist!\n\nLe téléchargement automatique des nouveaux épisodes est activé.`);
|
||||
}
|
||||
} catch (downloadError) {
|
||||
console.warn('Auto-download trigger failed:', downloadError);
|
||||
alert(`✅ "${result.anime_title}" a été ajouté à votre watchlist!\n\nLe téléchargement automatique des nouveaux épisodes est activé.`);
|
||||
}
|
||||
|
||||
// Update button to show it's already in watchlist
|
||||
updateAddButton(animeUrl, true);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding to watchlist:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update add button state
|
||||
*/
|
||||
function updateAddButton(animeUrl, isInWatchlist) {
|
||||
// Decode URL for matching
|
||||
let decodedUrl = animeUrl;
|
||||
try {
|
||||
decodedUrl = decodeURIComponent(animeUrl);
|
||||
} catch (e) {}
|
||||
|
||||
// Find all buttons for this anime (try both encoded and decoded)
|
||||
const buttons = document.querySelectorAll(`[data-watchlist-url="${encodeURIComponent(decodedUrl)}"], [data-watchlist-url="${decodedUrl}"]`);
|
||||
|
||||
buttons.forEach(button => {
|
||||
if (isInWatchlist) {
|
||||
button.innerHTML = '✓ Suivi';
|
||||
button.disabled = true;
|
||||
button.style.opacity = '0.6';
|
||||
} else {
|
||||
button.innerHTML = '+ Suivre';
|
||||
button.disabled = false;
|
||||
button.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause watchlist item
|
||||
*/
|
||||
async function handlePauseWatchlist(itemId) {
|
||||
try {
|
||||
await pauseWatchlistItem(itemId);
|
||||
await displayWatchlist();
|
||||
alert('✅ Anime mis en pause');
|
||||
} catch (error) {
|
||||
console.error('Error pausing item:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume watchlist item
|
||||
*/
|
||||
async function handleResumeWatchlist(itemId) {
|
||||
try {
|
||||
await resumeWatchlistItem(itemId);
|
||||
await displayWatchlist();
|
||||
alert('✅ Anime réactivé');
|
||||
} catch (error) {
|
||||
console.error('Error resuming item:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check specific item
|
||||
*/
|
||||
async function handleCheckItem(itemId) {
|
||||
const button = this;
|
||||
const originalText = button.innerHTML;
|
||||
|
||||
try {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '⏳...';
|
||||
|
||||
const result = await checkWatchlistItem(itemId);
|
||||
|
||||
if (result.new_episodes_found > 0) {
|
||||
alert(`🎉 ${result.new_episodes_found} nouveau(x) épisode(s) trouvé(s)!\n\n${result.episodes_downloaded.length} téléchargé(s)`);
|
||||
} else {
|
||||
alert('ℹ️ Aucun nouvel épisode trouvé');
|
||||
}
|
||||
|
||||
await displayWatchlist();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking item:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete watchlist item
|
||||
*/
|
||||
async function handleDeleteWatchlist(itemId) {
|
||||
if (!confirm('⚠️ Êtes-vous sûr de vouloir supprimer cet anime de votre watchlist ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteFromWatchlist(itemId);
|
||||
await displayWatchlist();
|
||||
alert('✅ Anime supprimé de la watchlist');
|
||||
} catch (error) {
|
||||
console.error('Error deleting item:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all items
|
||||
*/
|
||||
async function handleCheckAll() {
|
||||
const button = this;
|
||||
const originalText = button.innerHTML;
|
||||
|
||||
try {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '⏳ Vérification...';
|
||||
|
||||
const result = await checkAllWatchlistItems();
|
||||
|
||||
alert(`✅ Vérification terminée!\n\n${result.checked} animes vérifiés\n${result.total_new_episodes} nouveaux épisodes trouvés\n${result.total_downloaded} téléchargés`);
|
||||
|
||||
await displayWatchlist();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking all:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Create settings modal HTML
|
||||
*/
|
||||
function createSettingsModal(settings) {
|
||||
const modalHtml = `
|
||||
<div id="settingsModal" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000;">
|
||||
<div style="background: linear-gradient(135deg, #1e1e2e 0%, #2d1b69 100%); border-radius: 16px; padding: 30px; max-width: 500px; width: 90%; border: 1px solid rgba(0, 217, 255, 0.3);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px;">
|
||||
<h2 style="margin: 0; color: #00d9ff;">⚙️ Paramètres Watchlist</h2>
|
||||
<button onclick="closeSettingsModal()" style="background: none; border: none; color: #999; font-size: 24px; cursor: pointer;">×</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 20px;">
|
||||
<!-- Check Interval -->
|
||||
<div>
|
||||
<label style="display: block; color: #fff; margin-bottom: 8px; font-weight: 500;">
|
||||
🔄 Fréquence de vérification (heures)
|
||||
</label>
|
||||
<input type="number" id="checkInterval" value="${settings.check_interval_hours}" min="1" max="168"
|
||||
style="width: 100%; padding: 10px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; color: #fff; font-size: 14px;">
|
||||
<p style="font-size: 12px; color: #999; margin-top: 5px;">Entre 1 et 168 heures (1 semaine)</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto-download enabled -->
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div>
|
||||
<div style="color: #fff; font-weight: 500;">📥 Téléchargement automatique</div>
|
||||
<p style="font-size: 12px; color: #999; margin: 0;">Télécharger automatiquement les nouveaux épisodes</p>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="autoDownloadEnabled" ${settings.auto_download_enabled ? 'checked' : ''}>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Max concurrent downloads -->
|
||||
<div>
|
||||
<label style="display: block; color: #fff; margin-bottom: 8px; font-weight: 500;">
|
||||
⚡ Téléchargements simultanés max
|
||||
</label>
|
||||
<input type="number" id="maxConcurrent" value="${settings.max_concurrent_auto_downloads}" min="1" max="5"
|
||||
style="width: 100%; padding: 10px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; color: #fff; font-size: 14px;">
|
||||
<p style="font-size: 12px; color: #999; margin-top: 5px;">Maximum 5 téléchargements en parallèle</p>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div>
|
||||
<div style="color: #fff; font-weight: 500;">🔔 Notifications</div>
|
||||
<p style="font-size: 12px; color: #999; margin: 0;">Être notifié des nouveaux épisodes</p>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="notifyEnabled" ${settings.notify_on_new_episodes ? 'checked' : ''}>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; margin-top: 30px;">
|
||||
<button class="btn-primary modal-action-btn" onclick="saveSettings()">
|
||||
💾 Enregistrer
|
||||
</button>
|
||||
<button class="btn-secondary modal-action-btn" onclick="closeSettingsModal()">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
}
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255,255,255,0.2);
|
||||
transition: .4s;
|
||||
border-radius: 26px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .slider {
|
||||
background-color: #00d9ff;
|
||||
}
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
return modalHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close settings modal
|
||||
*/
|
||||
function closeSettingsModal() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings
|
||||
*/
|
||||
async function saveSettings() {
|
||||
try {
|
||||
const checkInterval = parseInt(document.getElementById('checkInterval').value);
|
||||
const autoDownloadEnabled = document.getElementById('autoDownloadEnabled').checked;
|
||||
const maxConcurrent = parseInt(document.getElementById('maxConcurrent').value);
|
||||
const notifyEnabled = document.getElementById('notifyEnabled').checked;
|
||||
|
||||
const settings = {
|
||||
check_interval_hours: checkInterval,
|
||||
auto_download_enabled: autoDownloadEnabled,
|
||||
max_concurrent_auto_downloads: maxConcurrent,
|
||||
notify_on_new_episodes: notifyEnabled
|
||||
};
|
||||
|
||||
await updateWatchlistSettings(settings);
|
||||
|
||||
// Restart scheduler if it's running to apply new interval
|
||||
const status = await getSchedulerStatus();
|
||||
if (status.running) {
|
||||
await stopScheduler();
|
||||
await startScheduler();
|
||||
}
|
||||
|
||||
closeSettingsModal();
|
||||
alert('✅ Paramètres enregistrés avec succès!');
|
||||
await loadSchedulerStatus();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions available globally
|
||||
// Global exposure for legacy calls
|
||||
window.displayWatchlist = displayWatchlist;
|
||||
window.handleAddToWatchlist = handleAddToWatchlist;
|
||||
window.handlePauseWatchlist = handlePauseWatchlist;
|
||||
window.handleResumeWatchlist = handleResumeWatchlist;
|
||||
window.handleCheckItem = handleCheckItem;
|
||||
window.handleDeleteWatchlist = handleDeleteWatchlist;
|
||||
window.handleCheckAll = handleCheckAll;
|
||||
window.createSettingsModal = createSettingsModal;
|
||||
window.closeSettingsModal = closeSettingsModal;
|
||||
window.saveSettings = saveSettings;
|
||||
window.handleDeleteFromWatchlist = (id) => {
|
||||
if (confirm('Retirer de la watchlist ?')) {
|
||||
htmx.ajax('DELETE', `/api/watchlist/${id}`, { target: `#watchlist-${id}`, swap: 'outerHTML' });
|
||||
}
|
||||
};
|
||||
|
||||
+12
-12
@@ -7,7 +7,7 @@
|
||||
* Get user's watchlist
|
||||
*/
|
||||
async function getWatchlist(status = null) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -34,7 +34,7 @@ async function getWatchlist(status = null) {
|
||||
* Add anime to watchlist
|
||||
*/
|
||||
async function addToWatchlist(animeData) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -60,7 +60,7 @@ async function addToWatchlist(animeData) {
|
||||
* Update watchlist item
|
||||
*/
|
||||
async function updateWatchlistItem(itemId, updateData) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -85,7 +85,7 @@ async function updateWatchlistItem(itemId, updateData) {
|
||||
* Delete from watchlist
|
||||
*/
|
||||
async function deleteFromWatchlist(itemId) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -122,7 +122,7 @@ async function resumeWatchlistItem(itemId) {
|
||||
* Check specific anime for new episodes
|
||||
*/
|
||||
async function checkWatchlistItem(itemId) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -145,7 +145,7 @@ async function checkWatchlistItem(itemId) {
|
||||
* Check all watchlist items
|
||||
*/
|
||||
async function checkAllWatchlistItems() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -168,7 +168,7 @@ async function checkAllWatchlistItems() {
|
||||
* Get watchlist settings
|
||||
*/
|
||||
async function getWatchlistSettings() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -190,7 +190,7 @@ async function getWatchlistSettings() {
|
||||
* Update watchlist settings
|
||||
*/
|
||||
async function updateWatchlistSettings(settings) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -215,7 +215,7 @@ async function updateWatchlistSettings(settings) {
|
||||
* Get watchlist statistics
|
||||
*/
|
||||
async function getWatchlistStats() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -237,7 +237,7 @@ async function getWatchlistStats() {
|
||||
* Get scheduler status
|
||||
*/
|
||||
async function getSchedulerStatus() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -259,7 +259,7 @@ async function getSchedulerStatus() {
|
||||
* Start scheduler
|
||||
*/
|
||||
async function startScheduler() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -282,7 +282,7 @@ async function startScheduler() {
|
||||
* Stop scheduler
|
||||
*/
|
||||
async function stopScheduler() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
Binary file not shown.
+53
-8
@@ -7,24 +7,69 @@
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
||||
|
||||
<!-- JavaScript -->
|
||||
<!-- External Libraries -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
<!-- Configure HTMX to include auth token in all requests -->
|
||||
<script>
|
||||
document.addEventListener('htmx:configRequest', (event) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
event.detail.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Legacy JavaScript (Refactored to HTMX/Alpine) -->
|
||||
<script src="/static/js/auth.js?v=1.10" defer></script>
|
||||
<script src="/static/js/api.js?v=1.11" defer></script>
|
||||
<script src="/static/js/utils.js?v=1.11" defer></script>
|
||||
<script src="/static/js/downloads.js?v=1.11" defer></script>
|
||||
<script src="/static/js/anime.js?v=1.11" defer></script>
|
||||
<script src="/static/js/anime-details.js?v=1.11" defer></script>
|
||||
<script src="/static/js/series-search.js?v=1.11" defer></script>
|
||||
<script src="/static/js/recommendations.js?v=1.11" defer></script>
|
||||
<script src="/static/js/tabs.js?v=1.11" defer></script>
|
||||
<!-- <script src="/static/js/anime.js?v=1.11" defer></script> -->
|
||||
<!-- <script src="/static/js/anime-details.js?v=1.12" defer></script> -->
|
||||
<!-- <script src="/static/js/series-search.js?v=1.11" defer></script> -->
|
||||
<!-- <script src="/static/js/recommendations.js?v=1.11" defer></script> -->
|
||||
<script src="/static/js/watchlist.js?v=1.11" defer></script>
|
||||
<script src="/static/js/watchlist-ui.js?v=1.11" defer></script>
|
||||
<!-- <script src="/static/js/watchlist-ui.js?v=1.11" defer></script> -->
|
||||
<script src="/static/js/main.js?v=1.11" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<body x-data="globalAppState">
|
||||
{% include "components/toast_container.html" %}
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Global State initialized when Alpine is ready
|
||||
document.addEventListener('alpine:init', () => {
|
||||
console.log('Alpine.js initializing...');
|
||||
Alpine.data('globalAppState', () => ({
|
||||
activeTab: 'home',
|
||||
isAuthenticated: true,
|
||||
username: '',
|
||||
init() {
|
||||
window.addEventListener('auth-success', (e) => {
|
||||
this.isAuthenticated = true;
|
||||
this.username = e.detail.username;
|
||||
});
|
||||
window.addEventListener('auth-logout', () => {
|
||||
this.isAuthenticated = false;
|
||||
this.username = '';
|
||||
});
|
||||
window.addEventListener('set-tab', (e) => {
|
||||
this.activeTab = e.detail.tab;
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user