Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87f245d3fc | |||
| 9e53579b36 | |||
| 0179ddbdf4 | |||
| 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
|
||||||
+17
-2
@@ -47,10 +47,25 @@ favorites.json
|
|||||||
ohm_streaming.db
|
ohm_streaming.db
|
||||||
|
|
||||||
# Config (runtime-generated)
|
# Config (runtime-generated)
|
||||||
config/anime_sama_domain.json
|
config/*.json
|
||||||
config/metadata_cache.json
|
config/domain_cache.json
|
||||||
|
!config/*.example.json
|
||||||
data/
|
data/
|
||||||
favorites.json
|
favorites.json
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
ohm_streaming.db
|
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/
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"active_plan": "/opt/Ohm_streaming/.sisyphus/plans/watchlist-visual-redesign.md",
|
"active_plan": "/opt/Ohm_streaming/.sisyphus/plans/cors-fix.md",
|
||||||
"started_at": "2026-02-26T14:52:06.065Z",
|
"started_at": "2026-03-18T13:17:43.401Z",
|
||||||
"session_ids": [
|
"session_ids": [
|
||||||
"ses_36604025effe0D8w29Z4LdkaPr"
|
"ses_3388359e2ffe5brQanNc9Qb8FL"
|
||||||
],
|
],
|
||||||
"plan_name": "watchlist-visual-redesign",
|
"plan_name": "cors-fix",
|
||||||
"agent": "atlas"
|
"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
|
```bash
|
||||||
# Setup
|
# Dev server
|
||||||
python3 -m venv venv && source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Run dev server
|
|
||||||
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
||||||
```
|
|
||||||
|
|
||||||
## Build, Lint & Test Commands
|
# --- Tests (pytest, configured in pytest.ini, asyncio_mode=auto) ---
|
||||||
|
|
||||||
### Running Tests
|
pytest # All tests (coverage + verbose by default)
|
||||||
|
pytest -m "unit" # Fast unit tests only
|
||||||
|
pytest -m "integration" # API integration tests
|
||||||
|
pytest -m "not slow" # CI default — excludes slow tests
|
||||||
|
pytest -m "network" # Tests requiring network access
|
||||||
|
|
||||||
```bash
|
# Single file / class / test
|
||||||
# All tests
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# With coverage (HTML report in htmlcov/)
|
|
||||||
pytest --cov=app --cov-report=html
|
|
||||||
|
|
||||||
# Unit only (fast)
|
|
||||||
pytest -m "unit"
|
|
||||||
|
|
||||||
# Integration tests only
|
|
||||||
pytest -m "integration"
|
|
||||||
|
|
||||||
# Exclude slow tests
|
|
||||||
pytest -m "not slow"
|
|
||||||
|
|
||||||
# Exclude network tests (mocked only)
|
|
||||||
pytest -m "not network"
|
|
||||||
|
|
||||||
# Verbose with print debugging
|
|
||||||
pytest -v -s
|
|
||||||
|
|
||||||
# Generate HTML report
|
|
||||||
pytest --html=report.html --self-contained-html
|
|
||||||
|
|
||||||
# Timeout per test (seconds)
|
|
||||||
pytest --timeout=30
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Single Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Specific file
|
|
||||||
pytest tests/test_sonarr.py -v
|
pytest tests/test_sonarr.py -v
|
||||||
|
|
||||||
# Specific class
|
|
||||||
pytest tests/test_sonarr.py::TestSonarrHandler -v
|
pytest tests/test_sonarr.py::TestSonarrHandler -v
|
||||||
|
|
||||||
# Specific test
|
|
||||||
pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
|
pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
|
||||||
|
|
||||||
# Pattern match
|
# Debug
|
||||||
pytest -k "test_download" -v
|
pytest -s # Show print() output
|
||||||
|
pytest --cov=app --cov-report=html # HTML coverage report in htmlcov/
|
||||||
|
|
||||||
|
# --- Lint & Format (ruff) ---
|
||||||
|
|
||||||
|
ruff check app/ # Lint
|
||||||
|
ruff format --check app/ # Format check (CI enforces this)
|
||||||
|
ruff format app/ # Auto-format
|
||||||
|
|
||||||
|
# --- Type Check ---
|
||||||
|
|
||||||
|
mypy app/ --ignore-missing-imports # Type check (CI enforces)
|
||||||
|
|
||||||
|
# --- DB Migrations ---
|
||||||
|
|
||||||
|
alembic revision --autogenerate -m "description"
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# --- Frontend (optional) ---
|
||||||
|
|
||||||
|
npm test # Vitest JS tests
|
||||||
|
npx playwright test # E2E browser tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Style
|
## CODE STYLE
|
||||||
|
|
||||||
### Imports (PEP 8 order)
|
### Imports
|
||||||
1. Standard library (`os`, `json`, `asyncio`)
|
Three groups separated by blank lines: stdlib → third-party → local (`app.*`).
|
||||||
2. Third-party (`httpx`, `beautifulsoup4`, `fastapi`)
|
Always absolute imports (no relative). No wildcard imports (`from X import *` enforced).
|
||||||
3. Local app (`app.config`, `app.utils`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
import os
|
|
||||||
import asyncio
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.models import DownloadTask, DownloadStatus
|
|
||||||
```
|
|
||||||
|
|
||||||
### Formatting
|
### Formatting
|
||||||
- **Line length**: 120 chars max
|
PEP 8, 120 chars max line length. Single quotes for strings, double quotes for docstrings.
|
||||||
- **Indentation**: 4 spaces
|
Ruff handles linting and formatting (no local config — CI-only).
|
||||||
- **Blank lines**: 2 between top-level, 1 between inline
|
|
||||||
|
|
||||||
### Type Annotations
|
### Types
|
||||||
- Use explicit types
|
Explicit type hints on all function signatures and return types.
|
||||||
- Use `Optional[X]` not `X | None`
|
Use `Optional[X]` from `typing`. Use `list[str]` / `dict[str, X]` (lowercase generics).
|
||||||
- Use `list[X]`, `dict[X, Y]`
|
Pydantic models for all API schemas. Return type annotations required on public methods.
|
||||||
|
|
||||||
```python
|
### Naming
|
||||||
# Good
|
- `snake_case` for functions, variables, constants
|
||||||
async def get_download_link(url: str, target_filename: Optional[str] = None) -> tuple[str, str]:
|
- `PascalCase` for classes and enums
|
||||||
results: list[dict[str, str]] = []
|
- `UPPER_SNAKE_CASE` for enum values (`DownloadStatus.PENDING`)
|
||||||
|
- `logger = logging.getLogger(__name__)` at module level
|
||||||
# Avoid
|
- `_` prefix for private methods (`_fetch_page`, `_sanitize`)
|
||||||
async def get_download_link(url, target_filename=None):
|
- `get_*` for factory functions (`get_downloader`, `get_anime_site`)
|
||||||
results = []
|
|
||||||
```
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
| Element | Convention | Example |
|
|
||||||
|---------|------------|---------|
|
|
||||||
| Modules | snake_case | `download_manager.py` |
|
|
||||||
| Classes | PascalCase | `DownloadManager` |
|
|
||||||
| Functions | snake_case | `get_download_link()` |
|
|
||||||
| Constants | UPPER_SNAKE | `MAX_PARALLEL_DOWNLOADS` |
|
|
||||||
| Variables | snake_case | `download_task` |
|
|
||||||
| Enums | PascalCase | `DownloadStatus` |
|
|
||||||
| Enum values | UPPER_SNAKE | `DownloadStatus.PENDING` |
|
|
||||||
|
|
||||||
### Async/Await
|
|
||||||
- Always use for I/O operations
|
|
||||||
- Close clients properly to avoid leaks
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def close(self):
|
|
||||||
await self.client.aclose()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
- Use try/except for recoverable errors
|
- `HTTPException` for API errors with proper status codes
|
||||||
- Raise specific exceptions (`HTTPException`, `ValueError`)
|
- `raise ValueError()` for business logic validation
|
||||||
- Never use empty except blocks
|
- `try/except` with logging — never bare `except:` (known tech debt exists)
|
||||||
- Log errors appropriately
|
- `response.raise_for_status()` for HTTP errors
|
||||||
|
- Never return `None` for missing URLs from downloaders — raise an exception
|
||||||
|
|
||||||
```python
|
### Docstrings
|
||||||
try:
|
Triple double quotes `"""`. Module docstrings describe purpose. Class docstrings describe
|
||||||
result = await client.get(url)
|
responsibility. Complex methods use Args/Returns sections. Not all methods require docstrings.
|
||||||
except httpx.TimeoutException:
|
|
||||||
logger.warning(f"Request timeout for {url}")
|
## ARCHITECTURE
|
||||||
raise HTTPException(status_code=504, detail="Request timeout")
|
|
||||||
|
```
|
||||||
|
main.py # App entry, middleware, startup, router registration
|
||||||
|
app/
|
||||||
|
├── routers/ # 11 APIRouter modules (one per feature domain)
|
||||||
|
├── downloaders/ # 3-tier: anime_sites/ → series_sites/ → video_players/
|
||||||
|
├── models/ # Pydantic/SQLModel (Base → Table → Schema pattern)
|
||||||
|
├── config.py # Pydantic Settings from .env
|
||||||
|
├── database.py # SQLModel engine (created at import time)
|
||||||
|
├── download_manager.py # Async queue, semaphore-based parallelism
|
||||||
|
├── auth.py # JWT + bcrypt, SQLModel user storage
|
||||||
|
├── providers.py # ANIME_PROVIDERS, SERIES_PROVIDERS, FILE_HOSTS registries
|
||||||
|
└── utils.py # sanitize_filename(), is_safe_filename()
|
||||||
|
templates/ # Jinja2 + HTMX + Alpine.js
|
||||||
|
static/js/ # Vanilla ES modules (no build step)
|
||||||
|
tests/ # pytest suite (conftest.py has shared fixtures)
|
||||||
|
config/ # Runtime JSON files (users, watchlist, sonarr)
|
||||||
|
alembic/ # DB migrations
|
||||||
```
|
```
|
||||||
|
|
||||||
### File Operations
|
## KEY CONVENTIONS
|
||||||
- Always sanitize filenames: `app.utils.sanitize_filename()`
|
|
||||||
- Validate paths: `app.utils.is_safe_filename()`
|
|
||||||
|
|
||||||
### Testing
|
- **URL pipe format**: `video_url|anime_page_url|episode_title` — metadata preserved through download pipeline
|
||||||
- Use pytest with pytest-asyncio
|
- **Module-level init**: `database.py` creates engine on import; `main.py` creates `download_manager` on import
|
||||||
- Mark tests: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.slow`, `@pytest.mark.network`
|
- **Async everywhere**: Always `async/await` for I/O — use `httpx.AsyncClient`, never `requests`
|
||||||
- Tests in `test_api.py` are auto-marked as integration, others as unit
|
- **Factory pattern**: `get_downloader(url)` routes anime → series → video player → generic
|
||||||
- Use fixtures from `tests/conftest.py`
|
- **Router deps**: `Depends(lambda: download_manager)`, `Depends(get_current_user_from_token)`, `Depends(lambda: templates)`
|
||||||
|
- **Dual storage**: Some features use JSON files (legacy) + SQLModel tables (newer)
|
||||||
|
- **Frontend**: No JS build step. HTMX for server interactions, Alpine.js for client state, Plyr.io for video
|
||||||
|
- **Circular import avoidance**: `episode_checker.py` uses lazy init (`set_download_manager()`)
|
||||||
|
|
||||||
```python
|
## ANTI-PATTERNS (DO NOT)
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_manager():
|
|
||||||
manager = DownloadManager(max_parallel=3)
|
|
||||||
assert manager.max_parallel == 3
|
|
||||||
|
|
||||||
# Mark slow tests
|
- Use sync `requests` — always `httpx.AsyncClient`
|
||||||
@pytest.mark.slow
|
- Return `None` for missing URLs from downloaders — raise an exception
|
||||||
async def test_full_download_flow():
|
- Skip `sanitize_filename()` on extracted filenames — path traversal risk
|
||||||
...
|
- Forget `await self.close()` in downloaders — resource leak
|
||||||
|
- Hardcode User-Agent in individual players — use base class headers
|
||||||
|
- Use `from X import *` — always explicit imports
|
||||||
|
- Import `download_manager` from `main.py` in app/ modules — causes circular imports
|
||||||
|
- Store secrets in `config/*.json` — use `.env`
|
||||||
|
- Use `as any`, `@ts-ignore` to suppress type errors (if adding TS)
|
||||||
|
|
||||||
# Mark tests requiring network
|
## TEST CONVENTIONS
|
||||||
@pytest.mark.network
|
|
||||||
async def test_external_api():
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security
|
- `tests/` directory with `conftest.py` for shared fixtures
|
||||||
- Never hardcode secrets - use environment variables
|
- Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.network`, `@pytest.mark.slow`
|
||||||
- Validate all inputs (URLs, filenames)
|
- `asyncio_mode = auto` — async test functions run without explicit marker
|
||||||
- Use HMAC for webhook verification when configured
|
- Test naming: `test_<verb>_<noun>` in `Test*` classes
|
||||||
- Limit CORS origins - never use `*` in production
|
- 300s timeout configured in pytest.ini; `testpaths = tests`
|
||||||
|
- Legacy test files at project root: `test_watchlist_simple.py`, `test_watchlist_e2e.py`
|
||||||
|
|
||||||
## Architecture Patterns
|
## ADDING NEW PROVIDERS
|
||||||
|
|
||||||
### Three-Tier Downloader Architecture
|
**Video player**: Create in `app/downloaders/video_players/`, inherit `BaseVideoPlayer`,
|
||||||
|
implement `can_handle()` + `get_download_link(url, target_filename=None)`, register in
|
||||||
|
`__init__.py`, add to `FILE_HOSTS` in `providers.py`.
|
||||||
|
|
||||||
The project uses a three-tier downloader system:
|
**Anime/series site**: Create in `app/downloaders/anime_sites/` or `series_sites/`, inherit
|
||||||
|
base class, implement `search_anime()` + `get_episodes()` + `get_anime_metadata()` +
|
||||||
|
`get_download_link()`, register in `__init__.py`, add to `providers.py`.
|
||||||
|
|
||||||
1. **Anime Catalogs** (`app/downloaders/anime_sites/`)
|
## NOTES
|
||||||
- `animesama.py` - Anime-Sama (primary)
|
|
||||||
- `animeultime.py` - Anime-Ultime
|
|
||||||
- `nekosama.py` - Neko-Sama
|
|
||||||
- `vostfree.py` - Vostfree
|
|
||||||
- `frenchmanga.py` - French-Manga
|
|
||||||
|
|
||||||
2. **Series Catalogs** (`app/downloaders/series_sites/`)
|
- Python 3.11+, CI tests on 3.11 and 3.12
|
||||||
- `fs7.py` - French Stream
|
- No `pyproject.toml` — uses `requirements.txt` with exact version pinning
|
||||||
|
- `GEMINI.md` and `CLAUDE.md` exist with tool-specific instructions (merge if conflicting)
|
||||||
3. **Video Players** (`app/downloaders/video_players/`)
|
- French-language project (animes, séries, VOSTFR) but all code and comments in English
|
||||||
- `sibnet.py`, `doodstream.py`, `vidmoly.py`, `uqload.py`
|
- ~20 empty `except:` blocks in downloaders/tests — known tech debt
|
||||||
- `uptobox.py`, `unfichier.py`, `rapidfile.py`
|
- `JWT_SECRET_KEY` min 32 chars, default rejected at startup; generate via `Settings.generate_secret()`
|
||||||
- `sendvid.py`, `lpayer.py`, `vidzy.py`, `luluv.py`
|
- Sub-AGENTS.md files exist in `app/`, `app/routers/`, `app/downloaders/`, `app/models/`,
|
||||||
- `oneupload.py`, `smoothpre.py`
|
`app/downloaders/anime_sites/`, `app/downloaders/video_players/` for deeper context
|
||||||
|
|
||||||
Each tier has a base class and factory pattern. When adding providers:
|
|
||||||
1. Inherit from appropriate base class (`base.py`)
|
|
||||||
2. Implement required methods (`search_anime`, `get_episodes`, `get_download_link`)
|
|
||||||
3. Register in `app/providers.py`
|
|
||||||
4. Add URL detection patterns
|
|
||||||
|
|
||||||
**URL Convention**: Pipe-separated format preserves metadata:
|
|
||||||
```
|
|
||||||
video_url|anime_page_url|episode_title
|
|
||||||
```
|
|
||||||
|
|
||||||
### Core Modules
|
|
||||||
|
|
||||||
| Module | Purpose |
|
|
||||||
|--------|---------|
|
|
||||||
| `app/watchlist.py` | Episode tracking & auto-download |
|
|
||||||
| `app/auto_download_scheduler.py` | APScheduler for periodic checks |
|
|
||||||
| `app/episode_checker.py` | New episode detection |
|
|
||||||
| `app/sonarr_handler.py` | Sonarr webhook integration |
|
|
||||||
| `app/recommendation_engine.py` | Personalized anime recommendations |
|
|
||||||
| `app/favorites.py` | User favorites management |
|
|
||||||
| `app/auth.py` | JWT authentication |
|
|
||||||
| `app/download_manager.py` | Download queue management |
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `main.py` | FastAPI app, all API endpoints |
|
|
||||||
| `app/config.py` | Pydantic Settings configuration |
|
|
||||||
| `app/download_manager.py` | Download queue & task management |
|
|
||||||
| `app/utils.py` | `sanitize_filename`, `is_safe_filename` |
|
|
||||||
| `app/auth.py` | JWT auth, user management |
|
|
||||||
| `app/providers.py` | Provider definitions & URL detection |
|
|
||||||
| `app/models/__init__.py` | Core Pydantic models |
|
|
||||||
| `app/models/watchlist.py` | Watchlist models |
|
|
||||||
| `app/models/sonarr.py` | Sonarr integration models |
|
|
||||||
| `app/models/auth.py` | Authentication models |
|
|
||||||
|
|
||||||
## Frontend Architecture
|
|
||||||
|
|
||||||
### JavaScript Modules (`static/js/`)
|
|
||||||
| Module | Purpose |
|
|
||||||
|--------|---------|
|
|
||||||
| `main.js` | Application entry point |
|
|
||||||
| `api.js` | API client functions |
|
|
||||||
| `auth.js` | Authentication handling |
|
|
||||||
| `tabs.js` | Tab navigation |
|
|
||||||
| `anime.js` | Anime search & display |
|
|
||||||
| `anime-details.js` | Anime detail views |
|
|
||||||
| `watchlist.js` | Watchlist API calls |
|
|
||||||
| `watchlist-ui.js` | Watchlist UI rendering |
|
|
||||||
| `downloads.js` | Download management UI |
|
|
||||||
| `recommendations.js` | Recommendations display |
|
|
||||||
| `series-search.js` | TV series search |
|
|
||||||
| `utils.js` | Utility functions |
|
|
||||||
|
|
||||||
### Templates (`templates/`)
|
|
||||||
| Template | Purpose |
|
|
||||||
|----------|---------|
|
|
||||||
| `base.html` | Base layout with CSS/JS imports |
|
|
||||||
| `index.html` | Main SPA interface |
|
|
||||||
| `login.html` | Login/register page |
|
|
||||||
| `watchlist.html` | Watchlist management page |
|
|
||||||
| `player.html` | Video player page |
|
|
||||||
| `components/` | Reusable HTML components |
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
- Use `.env` from `.env.example`
|
|
||||||
- `JWT_SECRET_KEY` must change in production
|
|
||||||
- Config files stored in `config/`:
|
|
||||||
- `users.json` - User database
|
|
||||||
- `watchlist.json` - Watchlist data
|
|
||||||
- `watchlist_settings.json` - Auto-download settings
|
|
||||||
- `sonarr.json` - Sonarr integration config
|
|
||||||
- `sonarr_mappings.json` - Series to anime mappings
|
|
||||||
|
|
||||||
## API Endpoints Overview
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- `POST /api/auth/register` - Register new user
|
|
||||||
- `POST /api/auth/login` - Login, get JWT token
|
|
||||||
- `GET /api/auth/me` - Get current user info
|
|
||||||
- `POST /api/auth/logout` - Logout (client-side)
|
|
||||||
|
|
||||||
### Downloads
|
|
||||||
- `POST /api/download` - Create download task
|
|
||||||
- `GET /api/downloads` - List all downloads
|
|
||||||
- `GET /api/download/{task_id}` - Get download status
|
|
||||||
- `POST /api/download/{task_id}/pause` - Pause download
|
|
||||||
- `POST /api/download/{task_id}/resume` - Resume download
|
|
||||||
- `DELETE /api/download/{task_id}` - Cancel/delete download
|
|
||||||
- `GET /api/download/{task_id}/file` - Download completed file
|
|
||||||
|
|
||||||
### Anime Search & Metadata
|
|
||||||
- `GET /api/anime/search` - Search across all anime providers
|
|
||||||
- `GET /api/series/search` - Search TV series providers
|
|
||||||
- `GET /api/anime/metadata` - Get detailed anime metadata
|
|
||||||
- `GET /api/anime/episodes` - Get episode list
|
|
||||||
- `GET /api/anime/seasons` - Get available seasons
|
|
||||||
- `POST /api/anime/download-season` - Download all episodes
|
|
||||||
|
|
||||||
### Watchlist
|
|
||||||
- `GET /api/watchlist` - List watchlist items
|
|
||||||
- `POST /api/watchlist` - Add to watchlist
|
|
||||||
- `PUT /api/watchlist/{item_id}` - Update watchlist item
|
|
||||||
- `DELETE /api/watchlist/{item_id}` - Remove from watchlist
|
|
||||||
- `GET /api/watchlist/settings` - Get auto-download settings
|
|
||||||
- `PUT /api/watchlist/settings` - Update settings
|
|
||||||
- `POST /api/watchlist/check` - Trigger manual episode check
|
|
||||||
|
|
||||||
### Favorites
|
|
||||||
- `GET /api/favorites` - List favorites
|
|
||||||
- `POST /api/favorites` - Add to favorites
|
|
||||||
- `DELETE /api/favorites/{anime_id}` - Remove from favorites
|
|
||||||
- `POST /api/favorites/toggle` - Toggle favorite status
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
- `GET /api/recommendations` - Get personalized recommendations
|
|
||||||
- `GET /api/releases/latest` - Get latest releases
|
|
||||||
- `GET /api/releases/seasonal` - Get seasonal anime
|
|
||||||
|
|
||||||
### Sonarr Integration
|
|
||||||
- `POST /api/sonarr/webhook` - Receive Sonarr webhooks
|
|
||||||
- `GET /api/sonarr/mappings` - List Sonarr mappings
|
|
||||||
- `POST /api/sonarr/mappings` - Create mapping
|
|
||||||
- `DELETE /api/sonarr/mappings/{series_id}` - Delete mapping
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### Core
|
|
||||||
- `fastapi` - Web framework
|
|
||||||
- `uvicorn` - ASGI server
|
|
||||||
- `httpx` - Async HTTP client
|
|
||||||
- `aiohttp` - Alternative HTTP client
|
|
||||||
- `pydantic` / `pydantic-settings` - Data validation & settings
|
|
||||||
|
|
||||||
### Scraping & Parsing
|
|
||||||
- `beautifulsoup4` - HTML parsing
|
|
||||||
- `lxml` - XML/HTML parser
|
|
||||||
- `jieba` - Chinese text segmentation
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- `python-jose` - JWT handling
|
|
||||||
- `passlib[bcrypt]` - Password hashing
|
|
||||||
|
|
||||||
### Scheduler
|
|
||||||
- `apscheduler` - Job scheduling for auto-downloads
|
|
||||||
|
|
||||||
### Cryptography
|
|
||||||
- `pycryptodome` - AES decryption for video players
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- `pytest` + `pytest-asyncio` - Async test support
|
|
||||||
- `pytest-cov` - Coverage reporting
|
|
||||||
- `pytest-mock` - Mocking utilities
|
|
||||||
- `pytest-timeout` - Test timeout protection
|
|
||||||
- `pytest-html` - HTML test reports
|
|
||||||
|
|||||||
@@ -16,12 +16,17 @@ source venv/bin/activate # On Windows: venv\Scripts\activate
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Install JavaScript test dependencies (optional, for frontend tests)
|
||||||
|
npm install
|
||||||
|
|
||||||
# Run development server (auto-reload)
|
# Run development server (auto-reload)
|
||||||
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
||||||
|
|
||||||
# Access web interface
|
# Access web interface
|
||||||
# Open http://localhost:3000/web in browser
|
# Open http://localhost:3000/web in browser
|
||||||
|
|
||||||
|
# --- Python Tests (pytest) ---
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
pytest
|
pytest
|
||||||
|
|
||||||
@@ -42,6 +47,26 @@ pytest -v
|
|||||||
|
|
||||||
# Show print debugging
|
# Show print debugging
|
||||||
pytest -s
|
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
|
## Architecture
|
||||||
@@ -49,8 +74,20 @@ pytest -s
|
|||||||
**Directory Structure:**
|
**Directory Structure:**
|
||||||
```
|
```
|
||||||
Ohm_streaming/
|
Ohm_streaming/
|
||||||
├── main.py # FastAPI application & API endpoints
|
├── main.py # FastAPI application startup & middleware
|
||||||
├── app/
|
├── 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.)
|
│ ├── models/ # Pydantic models (DownloadTask, AnimeMetadata, Sonarr, etc.)
|
||||||
│ ├── downloaders/ # Host-specific downloaders (organized structure)
|
│ ├── downloaders/ # Host-specific downloaders (organized structure)
|
||||||
│ │ ├── base.py # BaseDownloader abstract class (legacy, kept for compatibility)
|
│ │ ├── base.py # BaseDownloader abstract class (legacy, kept for compatibility)
|
||||||
@@ -100,7 +137,18 @@ Ohm_streaming/
|
|||||||
│ ├── player.html # Video player page
|
│ ├── player.html # Video player page
|
||||||
│ └── base.html # Base template
|
│ └── base.html # Base template
|
||||||
├── static/ # Static assets (CSS, JS, images)
|
├── 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:**
|
**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
|
- Each provider has: name, domains, icon, color, url_pattern
|
||||||
- `detect_provider_from_url(url)` - Identify provider from URL
|
- `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:**
|
**Download Management:**
|
||||||
- `POST /api/download` - Create new download task
|
- `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
|
- `GET /api/sonarr/suggest` - Suggest anime matches
|
||||||
- `POST /api/sonarr/download` - Manually trigger download
|
- `POST /api/sonarr/download` - Manually trigger download
|
||||||
|
|
||||||
### 5. Web Interface
|
### 6. Web Interface
|
||||||
- Single-page app at `/web` (templates/index.html)
|
- Single-page app at `/web` (templates/index.html)
|
||||||
- Auto-refreshes every second to show progress
|
- Auto-refreshes every second to show progress
|
||||||
- Video player with seeking support (HTTP Range headers)
|
- Video player with seeking support (HTTP Range headers)
|
||||||
- Dark theme with gradients and animations
|
- 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
|
- `sanitize_filename(filename, max_length=255)` - Sanitize filenames to prevent path traversal
|
||||||
- Removes dangerous characters: `\ / : * ? " < > |`
|
- Removes dangerous characters: `\ / : * ? " < > |`
|
||||||
- Strips path separators and leading dots/dashes
|
- 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
|
- Detects absolute paths and drive letters
|
||||||
- Used throughout the codebase for file operations
|
- 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`
|
- **UserManager** - JSON-based user storage in `config/users.json`
|
||||||
- User registration with bcrypt password hashing
|
- User registration with bcrypt password hashing
|
||||||
- Password truncated to 72 bytes (bcrypt limitation)
|
- Password truncated to 72 bytes (bcrypt limitation)
|
||||||
- User authentication and last login tracking
|
- User authentication and last login tracking
|
||||||
- **JWT Tokens** - Stateless authentication
|
- **JWT Tokens** - Stateless authentication with refresh token support
|
||||||
- 7-day token expiration (configurable via `ACCESS_TOKEN_EXPIRE_MINUTES`)
|
- Access tokens: 24-hour expiration (configurable via `ACCESS_TOKEN_EXPIRE_MINUTES`)
|
||||||
|
- Refresh tokens: 30-day expiration (stored in `config/refresh_tokens.json`)
|
||||||
- HS256 algorithm with JWT_SECRET_KEY (change in production!)
|
- HS256 algorithm with JWT_SECRET_KEY (change in production!)
|
||||||
- Token verification and user extraction
|
- Token verification and user extraction
|
||||||
- **Password Security**
|
- **Password Security**
|
||||||
- bcrypt hashing with passlib
|
- bcrypt hashing with passlib
|
||||||
- Automatic deprecated scheme migration
|
- 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**
|
- **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`
|
- Users stored in `config/users.json`
|
||||||
|
- Refresh tokens stored in `config/refresh_tokens.json`
|
||||||
|
|
||||||
**Authentication Endpoints:**
|
**Authentication Endpoints:**
|
||||||
- `POST /api/auth/register` - User registration
|
- `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
|
- `GET /api/auth/me` - Get current user profile
|
||||||
- `PUT /api/auth/me` - Update 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
|
- Analyzes download history to generate personalized recommendations
|
||||||
- Tracks genre preferences and viewing patterns
|
- Tracks genre preferences and viewing patterns
|
||||||
- Scores anime based on user's download history
|
- Scores anime based on user's download history
|
||||||
- Used by `/api/recommendations` endpoint
|
- 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
|
- Integrates with Kitsu anime database for metadata
|
||||||
- Fetches anime information by title or ID
|
- Fetches anime information by title or ID
|
||||||
- Provides enriched metadata (synopsis, genres, ratings, poster images)
|
- Provides enriched metadata (synopsis, genres, ratings, poster images)
|
||||||
- Used as fallback when provider metadata is incomplete
|
- Used as fallback when provider metadata is incomplete
|
||||||
|
|
||||||
### 10. Watchlist & Auto-Download System
|
### 11. Watchlist & Auto-Download System
|
||||||
|
|
||||||
**WatchlistManager** (`app/watchlist.py`):
|
**WatchlistManager** (`app/watchlist.py`):
|
||||||
- JSON-based storage in `config/watchlist.json`
|
- 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/start` - Start scheduler
|
||||||
- `POST /api/watchlist/scheduler/stop` - Stop scheduler
|
- `POST /api/watchlist/scheduler/stop` - Stop scheduler
|
||||||
|
|
||||||
### 11. Pydantic Models (`app/models/`)
|
### 12. Pydantic Models (`app/models/`)
|
||||||
- **`__init__.py`** - Core models:
|
- **`__init__.py`** - Core models:
|
||||||
- `DownloadStatus` - Enum for task states (PENDING, DOWNLOADING, PAUSED, COMPLETED, FAILED, CANCELLED)
|
- `DownloadStatus` - Enum for task states (PENDING, DOWNLOADING, PAUSED, COMPLETED, FAILED, CANCELLED)
|
||||||
- `HostType` - Enum for file host types (RAPIDFILE, UNFICHIER, DOODSTREAM, OTHER)
|
- `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 Structure
|
||||||
|
|
||||||
**Test Organization (tests/):**
|
**Python Test Organization (tests/):**
|
||||||
- `conftest.py` - Pytest configuration and fixtures
|
- `conftest.py` - Pytest configuration and fixtures
|
||||||
- `test_models.py` - Pydantic model tests
|
- `test_models.py` - Pydantic model tests
|
||||||
- `test_downloaders.py` - Downloader 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_translate_api.py` - Translation API tests
|
||||||
- `test_delete_and_restore.py` - Delete and restore functionality tests
|
- `test_delete_and_restore.py` - Delete and restore functionality tests
|
||||||
- `test_french_manga.py` - French-Manga provider 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:**
|
**Fixtures in conftest.py:**
|
||||||
- `temp_dir` - Temporary directory
|
- `temp_dir` - Temporary directory
|
||||||
@@ -550,6 +646,41 @@ To add a new anime streaming provider:
|
|||||||
Metadata should include:
|
Metadata should include:
|
||||||
- synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status
|
- 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
|
## Configuration
|
||||||
|
|
||||||
The application uses environment variables for configuration via `app/config.py` (Pydantic Settings).
|
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)
|
HTTP_TIMEOUT=10.0 # HTTP request timeout (seconds)
|
||||||
DOWNLOAD_TIMEOUT=300 # Download timeout (seconds)
|
DOWNLOAD_TIMEOUT=300 # Download timeout (seconds)
|
||||||
LOG_LEVEL=INFO # Logging level
|
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:**
|
**Configuration Files:**
|
||||||
- `.env` - Environment configuration (create from .env.example)
|
- `.env` - Environment configuration (create from .env.example)
|
||||||
- `config/users.json` - User authentication database (created automatically)
|
- `config/users.json` - User authentication database (created automatically)
|
||||||
|
- `config/refresh_tokens.json` - Refresh token storage (created automatically)
|
||||||
- `config/sonarr.json` - Sonarr webhook configuration (created automatically)
|
- `config/sonarr.json` - Sonarr webhook configuration (created automatically)
|
||||||
- `config/sonarr_mappings.json` - Sonarr to anime provider mappings (created automatically)
|
- `config/sonarr_mappings.json` - Sonarr to anime provider mappings (created automatically)
|
||||||
- `config/watchlist.json` - User watchlist items (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
|
- Configured in `main.py` via environment variables
|
||||||
|
|
||||||
**Authentication:**
|
**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
|
- bcrypt password hashing with passlib
|
||||||
- Passwords truncated to 72 bytes (bcrypt limitation)
|
- Passwords truncated to 72 bytes (bcrypt limitation)
|
||||||
|
- JWT secret key validation (minimum 32 characters, default rejected)
|
||||||
- Credentials stored in `config/users.json`
|
- Credentials stored in `config/users.json`
|
||||||
|
- Refresh tokens stored in `config/refresh_tokens.json`
|
||||||
|
|
||||||
## Key Implementation Details
|
## Key Implementation Details
|
||||||
|
|
||||||
@@ -654,11 +790,17 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key for auth
|
|||||||
- passlib[bcrypt] - Password hashing
|
- passlib[bcrypt] - Password hashing
|
||||||
- python-jose[cryptography] - JWT token handling
|
- python-jose[cryptography] - JWT token handling
|
||||||
- apscheduler - Task scheduling for auto-download
|
- apscheduler - Task scheduling for auto-download
|
||||||
|
- pydantic-settings - Environment-based configuration
|
||||||
|
|
||||||
**Testing:**
|
**Python Testing:**
|
||||||
- pytest - Test framework
|
- pytest - Test framework
|
||||||
- pytest-asyncio - Async test support
|
- pytest-asyncio - Async test support
|
||||||
- pytest-cov - Coverage reporting
|
- pytest-cov - Coverage reporting
|
||||||
- pytest-mock - Mocking support
|
- pytest-mock - Mocking support
|
||||||
- pytest-timeout - Test timeout handling
|
- pytest-timeout - Test timeout handling
|
||||||
- pytest-html - HTML test reports
|
- 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
|
# 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
|
## ✨ Fonctionnalités
|
||||||
|
|
||||||
### 🎬 Recherche et Téléchargement d'Animes
|
### 🎬 Recherche & Streaming
|
||||||
- **Recherche unifiée** : Recherchez sur 4 providers simultanément (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree)
|
- **Recherche unifiée** : Recherchez animes et séries TV simultanément via plusieurs sources.
|
||||||
- **Métadonnées riches** : Synopsis, genres, notes, année de sortie, studio, nombre d'épisodes, statut
|
- **Providers Anime** : Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga.
|
||||||
- **Téléchargement par épisode** : Sélectionnez et téléchargez des épisodes individuels
|
- **Providers Séries** : FS7 (French-Stream), Zone-Telechargement.
|
||||||
- **Téléchargement de saison complète** : Téléchargez tous les épisodes d'un coup
|
- **Métadonnées riches** : Synopsis, genres, notes, studio via intégration Kitsu/MAL.
|
||||||
- **Streaming vidéo** : Regardez vos animes directement dans le navigateur
|
- **Streaming vidéo** : Lecteur **Plyr.io** intégré supportant plus de 15 hébergeurs.
|
||||||
- **Recherche floue** : Gestion des fautes de frappe et variations de noms
|
- **Téléchargement flexible** : Épisode par épisode ou saison complète.
|
||||||
|
|
||||||
### 📁 Hébergeurs de Fichiers Supportés
|
### 🔐 Authentification
|
||||||
- **1fichier** (1fichier.com, 1fichier.fr)
|
- **Inscription / Connexion** : Système JWT avec tokens d'accès et de refresh.
|
||||||
- **Uptobox** (uptobox.com, uptobox.fr)
|
- **Sécurité** : Clé secrète configurable, tokens expirables (24h access, 30j refresh).
|
||||||
- **Doodstream** (doodstream.com, dood.to, dood.lol, etc.)
|
|
||||||
- **Rapidfile** (rapidfile.net, rapidfile.com)
|
|
||||||
|
|
||||||
### 🎥 Hébergeurs Vidéo Supportés
|
### 📋 Watchlist & Automatisation
|
||||||
- **VidMoly** (vidmoly.to, vidmoly.com)
|
- **Suivi intelligent** : Ajoutez des titres à votre watchlist pour ne rater aucun épisode.
|
||||||
- **SendVid** (sendvid.com)
|
- **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
|
### ⭐ Favoris & Recommandations
|
||||||
- **Téléchargements parallèles** : Jusqu'à 3 téléchargements simultanés
|
- **Favoris** : Sauvegardez vos animes préférés avec tri et filtres.
|
||||||
- **Pause/Reprise** : Contrôle total sur vos téléchargements
|
- **Recommandations** : Suggestions basées sur les tendances et sorties récentes.
|
||||||
- **Progression en temps réel** : Vitesse, progression, taille
|
- **Sorties saisonnières** : Suivi des sorties anime (top, latest, seasonal).
|
||||||
- **Reprise automatique** : Support des HTTP Range pour reprendre les téléchargements interrompus
|
|
||||||
|
|
||||||
### 🌐 Interface Web
|
### 🚀 Gestionnaire de Téléchargements
|
||||||
- **Design moderne** : Interface sombre avec gradients et animations
|
- **Multi-threading** : Jusqu'à 5 téléchargements simultanés avec gestion de file d'attente.
|
||||||
- **Responsive** : Fonctionne sur desktop et mobile
|
- **Pause/Reprise** : Support du protocole HTTP Range pour reprendre les téléchargements interrompus.
|
||||||
- **Mise à jour automatique** : Rafraîchissement chaque seconde
|
- **Progression Temps Réel** : Vitesse (Mo/s), pourcentage et estimation du temps restant.
|
||||||
- **Métadonnées visuelles** : Affichage des informations anime avec icônes
|
- **Sanitisation** : Nettoyage automatique des noms de fichiers pour une compatibilité maximale.
|
||||||
|
|
||||||
### 🔌 API REST
|
### ⚙️ Paramètres
|
||||||
- **Endpoints REST** : Intégration facile avec d'autres applications
|
- **Désactivation de providers** : Activez/désactivez les sources individuellement.
|
||||||
- **Documentation automatique** : Swagger UI disponible
|
- **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+
|
L'application repose sur une architecture moderne et robuste :
|
||||||
- pip
|
- **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
|
```bash
|
||||||
# Cloner le repository
|
# Cloner le repository
|
||||||
git clone https://github.com/votre-user/Ohm_streaming.git
|
git clone https://git.lanro.eu/Roman/ohm_streaming.git
|
||||||
cd Ohm_streaming
|
cd ohm_streaming
|
||||||
|
|
||||||
# Créer l'environnement virtuel
|
# Créer et activer l'environnement virtuel
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
source venv/bin/activate
|
||||||
|
|
||||||
# Installer les dépendances
|
# Installer les dépendances
|
||||||
pip install -r requirements.txt
|
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
|
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
Accédez à l'interface : http://localhost:3000/web
|
Ou via le script fourni :
|
||||||
|
|
||||||
## 📖 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 :**
|
|
||||||
```bash
|
```bash
|
||||||
curl "http://localhost:3000/api/anime/search?q=naruto&lang=vostfr&include_metadata=true"
|
./run_app.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
**Obtenir les épisodes d'un anime :**
|
**Points d'accès :**
|
||||||
```bash
|
- Interface web : `http://localhost:3000/web`
|
||||||
curl "http://localhost:3000/api/anime/episodes?url=https://anime-sama.si/catalogue/naruto/saison1/vostfr/&lang=vostfr"
|
- Documentation API : `http://localhost:3000/docs`
|
||||||
```
|
- Page de connexion : `http://localhost:3000/login`
|
||||||
|
|
||||||
**Télécharger une saison complète :**
|
## 🧪 Tests & Qualité
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:3000/api/anime/download-season?url=https://anime-sama.si/catalogue/naruto/saison1/vostfr/&lang=vostfr"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Créer un téléchargement direct :**
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3000/api/download \
|
# Backend (Pytest)
|
||||||
-H "Content-Type: application/json" \
|
pytest # Tous les tests
|
||||||
-d '{"url": "https://1fichier.com/?xxxxx"}'
|
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
|
## 🏗️ Structure du Projet
|
||||||
|
|
||||||
```
|
```
|
||||||
Ohm_streaming/
|
ohm_streaming/
|
||||||
├── main.py # Application FastAPI & endpoints API
|
├── main.py # Point d'entrée & Middleware FastAPI
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── models/ # Modèles Pydantic
|
│ ├── downloaders/ # Logique d'extraction (Scraping multi-tier)
|
||||||
│ │ └── __init__.py # DownloadTask, AnimeMetadata, etc.
|
│ │ ├── anime_sama.py # Downloader Anime-Sama
|
||||||
│ ├── downloaders/ # Downloaders par provider
|
│ │ ├── anime_ultime.py # Downloader Anime-Ultime
|
||||||
│ │ ├── base.py # Classe BaseDownloader
|
│ │ ├── neko_sama.py # Downloader Neko-Sama
|
||||||
│ │ ├── animesama.py # Anime-Sama (avec métadonnées)
|
│ │ ├── vostfree.py # Downloader Vostfree
|
||||||
│ │ ├── animeultime.py # Anime-Ultime (avec métadonnées)
|
│ │ ├── french_manga.py # Downloader French-Manga
|
||||||
│ │ ├── nekosama.py # Neko-Sama (avec métadonnées)
|
│ │ ├── fs7.py # Downloader FS7
|
||||||
│ │ ├── vostfree.py # Vostfree (avec métadonnées)
|
│ │ └── zone_telechargement.py # Downloader Zone-TG
|
||||||
│ │ ├── unfichier.py # 1fichier
|
│ ├── models/ # Modèles SQLModel & Pydantic
|
||||||
│ │ ├── uptobox.py # Uptobox
|
│ ├── routers/ # Routes API modulaires (~40 endpoints)
|
||||||
│ │ ├── doodstream.py # Doodstream
|
│ ├── download_manager.py # Moteur de téléchargement asynchrone
|
||||||
│ │ ├── rapidfile.py # Rapidfile
|
│ ├── watchlist.py # Logique métier du suivi
|
||||||
│ │ ├── vidmoly.py # VidMoly
|
│ ├── episode_checker.py # Vérification automatique de nouveaux épisodes
|
||||||
│ │ ├── sendvid.py # SendVid
|
│ ├── auto_download_scheduler.py # Planificateur de téléchargements
|
||||||
│ │ └── __init__.py # Registry des downloaders
|
│ ├── sonarr_handler.py # Intégration Sonarr
|
||||||
│ ├── providers.py # Configuration des providers
|
│ ├── metadata_enrichment.py # Enrichissement des métadonnées (Kitsu/MAL)
|
||||||
│ └── download_manager.py # Gestionnaire de file d'attente
|
│ ├── recommendations.py # Système de recommandations
|
||||||
├── downloads/ # Fichiers téléchargés
|
│ ├── providers_manager.py # Gestion des providers (health check, activation)
|
||||||
├── templates/
|
│ └── database.py # Configuration de la base de données
|
||||||
│ ├── index.html # Interface web principale
|
├── config/ # Fichiers de configuration (Sonarr, mappings)
|
||||||
│ └── player.html # Lecteur vidéo
|
├── alembic/ # Migrations de base de données
|
||||||
├── static/ # Fichiers statiques (CSS, JS, images)
|
├── static/ # Frontend (JS, CSS, Images)
|
||||||
└── requirements.txt # Dépendances Python
|
├── 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
|
## 🐛 Problèmes Connus
|
||||||
download_manager = DownloadManager(
|
|
||||||
download_dir="downloads", # Répertoire de stockage
|
|
||||||
max_parallel=3 # Téléchargements simultanés
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 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` :
|
- Ce projet est à usage **éducatif et personnel** uniquement.
|
||||||
```python
|
- Respectez les droits d'auteur et les conditions d'utilisation des sites sources.
|
||||||
from .base import BaseDownloader
|
- L'utilisation de ce logiciel est sous votre entière responsabilité.
|
||||||
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é
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
**Version actuelle : 2.4**
|
||||||
|
**Dernière mise à jour : Avril 2026**
|
||||||
**Développé avec ❤️ pour la communauté anime**
|
**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,30 @@
|
|||||||
|
"""Initial migration
|
||||||
|
|
||||||
|
Revision ID: e0273f326a15
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-03-24 17:05:50.046027
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e0273f326a15'
|
||||||
|
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! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Add WatchlistSettingsTable
|
||||||
|
|
||||||
|
Revision ID: e88271d11851
|
||||||
|
Revises: e0273f326a15
|
||||||
|
Create Date: 2026-03-24 17:07:10.189457
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e88271d11851'
|
||||||
|
down_revision: Union[str, None] = 'e0273f326a15'
|
||||||
|
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! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### 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`
|
||||||
+247
-80
@@ -1,122 +1,124 @@
|
|||||||
"""User authentication and management system"""
|
"""User authentication and management system with SQLModel support"""
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict, List
|
||||||
|
from jose import jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
import logging
|
import logging
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from app.database import engine
|
||||||
|
from app.models.auth import UserTable
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Load settings at module level for easier mocking and access
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
# Password hashing context
|
# Password hashing context
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
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:
|
class UserManager:
|
||||||
"""Manages user storage and authentication"""
|
"""Manages user storage and authentication using SQL database"""
|
||||||
|
|
||||||
def __init__(self, db_file: str = USERS_DB_FILE):
|
def __init__(self):
|
||||||
self.db_file = db_file
|
# Database connection is managed via engine and sessions
|
||||||
self.users: Dict[str, dict] = {}
|
pass
|
||||||
self._load_users()
|
|
||||||
|
|
||||||
def _load_users(self):
|
def get_user(self, username: str) -> Optional[UserTable]:
|
||||||
"""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]:
|
|
||||||
"""Get user by username"""
|
"""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"""
|
"""Get user by ID"""
|
||||||
for user in self.users.values():
|
with Session(engine) as session:
|
||||||
if user.get('id') == user_id:
|
statement = select(UserTable).where(UserTable.id == user_id)
|
||||||
return user
|
return session.exec(statement).first()
|
||||||
return None
|
|
||||||
|
|
||||||
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"""
|
"""Create a new user"""
|
||||||
if username in self.users:
|
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")
|
raise ValueError(f"Username '{username}' already exists")
|
||||||
|
|
||||||
# Truncate password to 72 bytes if necessary (bcrypt limitation)
|
# Truncate password to 72 bytes if necessary (bcrypt limitation)
|
||||||
password_bytes = password.encode('utf-8')
|
password_bytes = password.encode("utf-8")
|
||||||
if len(password_bytes) > 72:
|
if len(password_bytes) > 72:
|
||||||
password = password_bytes[:72].decode('utf-8', errors='ignore')
|
password = password_bytes[:72].decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
# Hash password
|
# Hash password
|
||||||
hashed_password = pwd_context.hash(password)
|
hashed_password = pwd_context.hash(password)
|
||||||
|
|
||||||
# Create user
|
# Create user
|
||||||
user = {
|
user = UserTable(
|
||||||
"id": hashlib.sha256(username.encode()).hexdigest()[:32],
|
username=username,
|
||||||
"username": username,
|
email=email,
|
||||||
"email": email,
|
full_name=full_name,
|
||||||
"full_name": full_name,
|
hashed_password=hashed_password,
|
||||||
"hashed_password": hashed_password,
|
is_active=True,
|
||||||
"is_active": True,
|
created_at=datetime.now(),
|
||||||
"created_at": datetime.now().isoformat(),
|
)
|
||||||
"last_login": None
|
|
||||||
}
|
|
||||||
|
|
||||||
self.users[username] = user
|
session.add(user)
|
||||||
self._save_users()
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
|
||||||
logger.info(f"Created user: {username}")
|
logger.info(f"Created user: {username}")
|
||||||
return user
|
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"""
|
"""Authenticate user with username and password"""
|
||||||
user = self.get_user(username)
|
user = self.get_user(username)
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not pwd_context.verify(password, user["hashed_password"]):
|
if not pwd_context.verify(password, user.hashed_password):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Update last login
|
# Update last login
|
||||||
user["last_login"] = datetime.now().isoformat()
|
with Session(engine) as session:
|
||||||
self._save_users()
|
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
|
return user
|
||||||
|
|
||||||
def update_last_login(self, username: str):
|
def update_user(self, user_id: str, update_data: dict) -> Optional[UserTable]:
|
||||||
"""Update user's last login time"""
|
"""Update user information"""
|
||||||
user = self.get_user(username)
|
with Session(engine) as session:
|
||||||
if user:
|
db_user = session.get(UserTable, user_id)
|
||||||
user["last_login"] = datetime.now().isoformat()
|
if not db_user:
|
||||||
self._save_users()
|
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
|
# Global user manager instance
|
||||||
@@ -135,7 +137,9 @@ def get_password_hash(password: str) -> str:
|
|||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
|
def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
|
||||||
"""Create JWT access token"""
|
"""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()
|
to_encode = data.copy()
|
||||||
|
|
||||||
@@ -152,9 +156,11 @@ def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
|
|||||||
|
|
||||||
def verify_token(token: str) -> Optional[str]:
|
def verify_token(token: str) -> Optional[str]:
|
||||||
"""Verify JWT token and return username"""
|
"""Verify JWT token and return username"""
|
||||||
from jose import jwt
|
|
||||||
from jose.exceptions import JWTError
|
from jose.exceptions import JWTError
|
||||||
|
|
||||||
|
SECRET_KEY = settings.jwt_secret_key
|
||||||
|
ALGORITHM = settings.jwt_algorithm
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
@@ -169,11 +175,7 @@ def verify_token(token: str) -> Optional[str]:
|
|||||||
get_user_from_token = verify_token
|
get_user_from_token = verify_token
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(credentials: HTTPAuthorizationCredentials) -> dict:
|
def get_current_user(credentials: HTTPAuthorizationCredentials) -> UserTable:
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(credentials: HTTPAuthorizationCredentials) -> dict:
|
|
||||||
"""Get current user from JWT token"""
|
"""Get current user from JWT token"""
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
username = verify_token(token)
|
username = verify_token(token)
|
||||||
@@ -181,7 +183,172 @@ def get_current_user(credentials: HTTPAuthorizationCredentials) -> dict:
|
|||||||
user = user_manager.get_user(username)
|
user = user_manager.get_user(username)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="User not found")
|
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")
|
raise HTTPException(status_code=401, detail="Inactive user")
|
||||||
return user
|
return user
|
||||||
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
||||||
|
|
||||||
|
|
||||||
|
# Refresh tokens storage
|
||||||
|
REFRESH_TOKENS_FILE = "config/refresh_tokens.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_refresh_tokens() -> Dict[str, dict]:
|
||||||
|
"""Load refresh tokens from file"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.exists(REFRESH_TOKENS_FILE):
|
||||||
|
with open(REFRESH_TOKENS_FILE, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading refresh tokens: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_refresh_tokens(tokens: Dict[str, dict]):
|
||||||
|
"""Save refresh tokens to file"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True)
|
||||||
|
with open(REFRESH_TOKENS_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(tokens, f, indent=2, ensure_ascii=False, default=str)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving refresh tokens: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
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 mapping
|
||||||
|
refresh_tokens = _load_refresh_tokens()
|
||||||
|
refresh_tokens[token_id] = {
|
||||||
|
"username": data["sub"],
|
||||||
|
"token_id": token_id,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"expires_at": refresh_expire.isoformat(),
|
||||||
|
}
|
||||||
|
_save_refresh_tokens(refresh_tokens)
|
||||||
|
|
||||||
|
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 storage
|
||||||
|
refresh_tokens = _load_refresh_tokens()
|
||||||
|
stored_token = refresh_tokens.get(token_id)
|
||||||
|
|
||||||
|
if not stored_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify token hasn't been revoked or expired
|
||||||
|
if stored_token.get("revoked"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return username
|
||||||
|
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
refresh_tokens = _load_refresh_tokens()
|
||||||
|
if token_id in refresh_tokens:
|
||||||
|
refresh_tokens[token_id]["revoked"] = True
|
||||||
|
refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat()
|
||||||
|
_save_refresh_tokens(refresh_tokens)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except JWTError:
|
||||||
|
return False
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from apscheduler.triggers.interval import IntervalTrigger
|
|||||||
|
|
||||||
from app.watchlist import watchlist_manager, WatchlistManager
|
from app.watchlist import watchlist_manager, WatchlistManager
|
||||||
from app.episode_checker import EpisodeChecker, episode_checker
|
from app.episode_checker import EpisodeChecker, episode_checker
|
||||||
|
from app.providers_manager import providers_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ class AutoDownloadScheduler:
|
|||||||
):
|
):
|
||||||
self.wlm = wlm or watchlist_manager
|
self.wlm = wlm or watchlist_manager
|
||||||
self.checker = checker or episode_checker
|
self.checker = checker or episode_checker
|
||||||
|
self.providers_mgr = providers_manager
|
||||||
self.scheduler: Optional[AsyncIOScheduler] = None
|
self.scheduler: Optional[AsyncIOScheduler] = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
@@ -46,6 +48,14 @@ class AutoDownloadScheduler:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in scheduled check job: {e}", exc_info=True)
|
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):
|
def start(self):
|
||||||
"""Start the scheduler"""
|
"""Start the scheduler"""
|
||||||
if self._running:
|
if self._running:
|
||||||
@@ -56,10 +66,10 @@ class AutoDownloadScheduler:
|
|||||||
self.scheduler = AsyncIOScheduler()
|
self.scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
# Get initial check interval from settings
|
# Get initial check interval from settings
|
||||||
settings = self.wlm.get_settings()
|
settings = self.wlm.settings
|
||||||
interval_hours = settings.check_interval_hours
|
interval_hours = settings.check_interval_hours
|
||||||
|
|
||||||
# Add the job
|
# Add the job for episode checking
|
||||||
self.scheduler.add_job(
|
self.scheduler.add_job(
|
||||||
self._check_job,
|
self._check_job,
|
||||||
trigger=IntervalTrigger(hours=interval_hours),
|
trigger=IntervalTrigger(hours=interval_hours),
|
||||||
@@ -68,6 +78,15 @@ class AutoDownloadScheduler:
|
|||||||
replace_existing=True
|
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
|
# Start the scheduler
|
||||||
self.scheduler.start()
|
self.scheduler.start()
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -149,6 +168,15 @@ class AutoDownloadScheduler:
|
|||||||
logger.error(f"Error in manual check: {e}", exc_info=True)
|
logger.error(f"Error in manual check: {e}", exc_info=True)
|
||||||
raise
|
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
|
# Global scheduler instance
|
||||||
auto_download_scheduler = AutoDownloadScheduler()
|
auto_download_scheduler = AutoDownloadScheduler()
|
||||||
|
|||||||
+38
-2
@@ -1,7 +1,11 @@
|
|||||||
"""Application configuration using environment variables"""
|
"""Application configuration using environment variables"""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic import model_validator
|
||||||
from typing import List
|
from typing import List
|
||||||
import os
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Application settings loaded from environment variables"""
|
"""Application settings loaded from environment variables"""
|
||||||
@@ -16,6 +20,38 @@ class Settings(BaseSettings):
|
|||||||
port: int = 3000
|
port: int = 3000
|
||||||
reload: bool = True
|
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
|
# Downloads
|
||||||
download_dir: str = "downloads"
|
download_dir: str = "downloads"
|
||||||
max_parallel_downloads: int = 3
|
max_parallel_downloads: int = 3
|
||||||
@@ -26,7 +62,7 @@ class Settings(BaseSettings):
|
|||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
"http://192.168.1.204:3000",
|
"http://192.168.1.204:3000",
|
||||||
"http://192.168.1.204"
|
"http://192.168.1.204",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""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
|
||||||
|
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
|
||||||
|
from app.models.download import DownloadTaskTable
|
||||||
|
|
||||||
|
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
|
||||||
+240
-4
@@ -7,7 +7,11 @@ from pathlib import Path
|
|||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
from app.models import DownloadTask, DownloadStatus, DownloadRequest
|
from app.models import DownloadTask, DownloadStatus, DownloadRequest
|
||||||
|
from app.models.download import DownloadTaskTable
|
||||||
|
from app.database import engine
|
||||||
|
from sqlmodel import Session, select
|
||||||
from app.downloaders import get_downloader
|
from app.downloaders import get_downloader
|
||||||
|
from app.utils import sanitize_filename
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -23,6 +27,92 @@ class DownloadManager:
|
|||||||
self.active_downloads: Dict[str, asyncio.Task] = {}
|
self.active_downloads: Dict[str, asyncio.Task] = {}
|
||||||
self._semaphore = asyncio.Semaphore(max_parallel)
|
self._semaphore = asyncio.Semaphore(max_parallel)
|
||||||
|
|
||||||
|
# ==================== DB Persistence ====================
|
||||||
|
|
||||||
|
def _save_task_to_db(self, task: DownloadTask) -> None:
|
||||||
|
"""Persist a download task to the database (upsert)."""
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
existing = session.get(DownloadTaskTable, task.id)
|
||||||
|
if existing:
|
||||||
|
existing.url = task.url
|
||||||
|
existing.filename = task.filename
|
||||||
|
existing.host = task.host.value if hasattr(task.host, 'value') else str(task.host)
|
||||||
|
existing.status = task.status.value if hasattr(task.status, 'value') else str(task.status)
|
||||||
|
existing.progress = task.progress
|
||||||
|
existing.downloaded_bytes = task.downloaded_bytes
|
||||||
|
existing.total_bytes = task.total_bytes
|
||||||
|
existing.speed = task.speed
|
||||||
|
existing.error = task.error
|
||||||
|
existing.started_at = task.started_at
|
||||||
|
existing.completed_at = task.completed_at
|
||||||
|
existing.file_path = task.file_path
|
||||||
|
session.add(existing)
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
db_task = DownloadTaskTable(
|
||||||
|
id=task.id,
|
||||||
|
url=task.url,
|
||||||
|
filename=task.filename,
|
||||||
|
host=task.host.value if hasattr(task.host, 'value') else str(task.host),
|
||||||
|
status=task.status.value if hasattr(task.status, 'value') else str(task.status),
|
||||||
|
progress=task.progress,
|
||||||
|
downloaded_bytes=task.downloaded_bytes,
|
||||||
|
total_bytes=task.total_bytes,
|
||||||
|
speed=task.speed,
|
||||||
|
error=task.error,
|
||||||
|
created_at=task.created_at,
|
||||||
|
started_at=task.started_at,
|
||||||
|
completed_at=task.completed_at,
|
||||||
|
file_path=task.file_path,
|
||||||
|
)
|
||||||
|
session.add(db_task)
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save task {task.id} to DB: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _delete_task_from_db(self, task_id: str) -> None:
|
||||||
|
"""Remove a download task from the database."""
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
db_task = session.get(DownloadTaskTable, task_id)
|
||||||
|
if db_task:
|
||||||
|
session.delete(db_task)
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete task {task_id} from DB: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _load_tasks_from_db(self) -> None:
|
||||||
|
"""Load persisted download tasks from the database into memory."""
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
statement = select(DownloadTaskTable)
|
||||||
|
db_tasks = session.exec(statement).all()
|
||||||
|
for db_task in db_tasks:
|
||||||
|
if db_task.id not in self.tasks:
|
||||||
|
task = DownloadTask(
|
||||||
|
id=db_task.id,
|
||||||
|
url=db_task.url,
|
||||||
|
filename=db_task.filename,
|
||||||
|
host="other",
|
||||||
|
status=DownloadStatus(db_task.status),
|
||||||
|
progress=db_task.progress,
|
||||||
|
downloaded_bytes=db_task.downloaded_bytes,
|
||||||
|
total_bytes=db_task.total_bytes,
|
||||||
|
speed=db_task.speed,
|
||||||
|
error=db_task.error,
|
||||||
|
created_at=db_task.created_at,
|
||||||
|
started_at=db_task.started_at,
|
||||||
|
completed_at=db_task.completed_at,
|
||||||
|
file_path=db_task.file_path,
|
||||||
|
)
|
||||||
|
self.tasks[task.id] = task
|
||||||
|
logger.info(f"Loaded {len(db_tasks)} download tasks from database")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load tasks from DB: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# ==================== Task Management ====================
|
||||||
|
|
||||||
def get_task(self, task_id: str) -> Optional[DownloadTask]:
|
def get_task(self, task_id: str) -> Optional[DownloadTask]:
|
||||||
return self.tasks.get(task_id)
|
return self.tasks.get(task_id)
|
||||||
|
|
||||||
@@ -59,6 +149,8 @@ class DownloadManager:
|
|||||||
created_at=datetime.now()
|
created_at=datetime.now()
|
||||||
)
|
)
|
||||||
self.tasks[task_id] = task
|
self.tasks[task_id] = task
|
||||||
|
# Persist to database
|
||||||
|
self._save_task_to_db(task)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
async def start_download(self, task_id: str):
|
async def start_download(self, task_id: str):
|
||||||
@@ -81,6 +173,7 @@ class DownloadManager:
|
|||||||
task = self.tasks.get(task_id)
|
task = self.tasks.get(task_id)
|
||||||
if task and task.status == DownloadStatus.DOWNLOADING:
|
if task and task.status == DownloadStatus.DOWNLOADING:
|
||||||
task.status = DownloadStatus.PAUSED
|
task.status = DownloadStatus.PAUSED
|
||||||
|
self._save_task_to_db(task)
|
||||||
if task_id in self.active_downloads:
|
if task_id in self.active_downloads:
|
||||||
self.active_downloads[task_id].cancel()
|
self.active_downloads[task_id].cancel()
|
||||||
del self.active_downloads[task_id]
|
del self.active_downloads[task_id]
|
||||||
@@ -89,6 +182,7 @@ class DownloadManager:
|
|||||||
task = self.tasks.get(task_id)
|
task = self.tasks.get(task_id)
|
||||||
if task:
|
if task:
|
||||||
task.status = DownloadStatus.CANCELLED
|
task.status = DownloadStatus.CANCELLED
|
||||||
|
self._save_task_to_db(task)
|
||||||
if task_id in self.active_downloads:
|
if task_id in self.active_downloads:
|
||||||
self.active_downloads[task_id].cancel()
|
self.active_downloads[task_id].cancel()
|
||||||
del self.active_downloads[task_id]
|
del self.active_downloads[task_id]
|
||||||
@@ -111,25 +205,32 @@ class DownloadManager:
|
|||||||
if task.file_path and os.path.exists(task.file_path):
|
if task.file_path and os.path.exists(task.file_path):
|
||||||
os.remove(task.file_path)
|
os.remove(task.file_path)
|
||||||
|
|
||||||
# Remove from tasks dict
|
# Remove from tasks dict and database
|
||||||
del self.tasks[task_id]
|
del self.tasks[task_id]
|
||||||
|
self._delete_task_from_db(task_id)
|
||||||
|
|
||||||
async def _download(self, task: DownloadTask):
|
async def _download(self, task: DownloadTask):
|
||||||
async with self._semaphore:
|
async with self._semaphore:
|
||||||
try:
|
try:
|
||||||
task.status = DownloadStatus.DOWNLOADING
|
task.status = DownloadStatus.DOWNLOADING
|
||||||
task.started_at = datetime.now()
|
task.started_at = datetime.now()
|
||||||
|
self._save_task_to_db(task)
|
||||||
|
|
||||||
# Get downloader and extract link
|
# Get downloader and extract link
|
||||||
downloader = get_downloader(task.url)
|
downloader = get_downloader(task.url)
|
||||||
|
|
||||||
# Extract episode title from pipe-separated URL if present
|
# 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
|
target_filename = None
|
||||||
if '|' in task.url:
|
if '|' in task.url:
|
||||||
parts = task.url.split('|')
|
parts = task.url.split('|')
|
||||||
if len(parts) >= 3:
|
# Last part is episode title, second to last is anime page URL
|
||||||
target_filename = parts[2].strip()
|
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}")
|
logger.debug(f"Extracted target filename from pipe: {target_filename}")
|
||||||
|
|
||||||
download_url, filename = await downloader.get_download_link(task.url, target_filename)
|
download_url, filename = await downloader.get_download_link(task.url, target_filename)
|
||||||
@@ -144,16 +245,33 @@ class DownloadManager:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"Task filename kept as: {task.filename}")
|
logger.debug(f"Task filename kept as: {task.filename}")
|
||||||
|
|
||||||
|
# Sanitize filename to prevent path traversal and invalid characters
|
||||||
|
task.filename = sanitize_filename(task.filename)
|
||||||
|
|
||||||
task.file_path = str(self.download_dir / task.filename)
|
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:
|
||||||
|
self._save_task_to_db(task)
|
||||||
|
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)
|
# Check if download_url is a local file path (VidMoly M3U8 pre-download)
|
||||||
if os.path.exists(download_url):
|
if os.path.exists(download_url):
|
||||||
logger.info(f"VidMoly already downloaded file to: {download_url}")
|
logger.info(f"VidMoly already downloaded file to: {download_url}")
|
||||||
# Move file to expected location if different
|
# Move file to expected location if different
|
||||||
import shutil
|
import shutil
|
||||||
if download_url != task.file_path:
|
if download_url != task.file_path:
|
||||||
|
try:
|
||||||
shutil.move(download_url, task.file_path)
|
shutil.move(download_url, task.file_path)
|
||||||
logger.debug(f"Moved file to: {task.file_path}")
|
logger.debug(f"Moved file to: {task.file_path}")
|
||||||
|
except shutil.Error:
|
||||||
|
# Same file, no move needed
|
||||||
|
pass
|
||||||
|
|
||||||
# Mark as complete
|
# Mark as complete
|
||||||
file_size = os.path.getsize(task.file_path)
|
file_size = os.path.getsize(task.file_path)
|
||||||
@@ -163,6 +281,7 @@ class DownloadManager:
|
|||||||
task.downloaded_bytes = file_size
|
task.downloaded_bytes = file_size
|
||||||
task.total_bytes = file_size
|
task.total_bytes = file_size
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
|
self._save_task_to_db(task)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if file already exists and is complete (for VidMoly which downloads directly)
|
# Check if file already exists and is complete (for VidMoly which downloads directly)
|
||||||
@@ -175,6 +294,7 @@ class DownloadManager:
|
|||||||
task.downloaded_bytes = file_size
|
task.downloaded_bytes = file_size
|
||||||
task.total_bytes = file_size
|
task.total_bytes = file_size
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
|
self._save_task_to_db(task)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check for partial download (resume)
|
# Check for partial download (resume)
|
||||||
@@ -226,6 +346,7 @@ class DownloadManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
task.status = DownloadStatus.FAILED
|
task.status = DownloadStatus.FAILED
|
||||||
task.error = str(e)
|
task.error = str(e)
|
||||||
|
self._save_task_to_db(task)
|
||||||
finally:
|
finally:
|
||||||
if task.id in self.active_downloads:
|
if task.id in self.active_downloads:
|
||||||
del self.active_downloads[task.id]
|
del self.active_downloads[task.id]
|
||||||
@@ -254,9 +375,11 @@ class DownloadManager:
|
|||||||
|
|
||||||
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
||||||
if task.status == DownloadStatus.CANCELLED:
|
if task.status == DownloadStatus.CANCELLED:
|
||||||
|
self._save_task_to_db(task)
|
||||||
return
|
return
|
||||||
|
|
||||||
if task.status == DownloadStatus.PAUSED:
|
if task.status == DownloadStatus.PAUSED:
|
||||||
|
self._save_task_to_db(task)
|
||||||
return
|
return
|
||||||
|
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
@@ -279,3 +402,116 @@ class DownloadManager:
|
|||||||
# Log completion info
|
# Log completion info
|
||||||
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
|
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)")
|
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")
|
||||||
|
|
||||||
|
# Persist to database
|
||||||
|
self._save_task_to_db(task)
|
||||||
|
|
||||||
|
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()
|
||||||
|
self._save_task_to_db(task)
|
||||||
|
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
|
||||||
@@ -24,7 +24,8 @@ from .anime_sites import (
|
|||||||
from .series_sites import (
|
from .series_sites import (
|
||||||
BaseSeriesSite,
|
BaseSeriesSite,
|
||||||
get_series_site,
|
get_series_site,
|
||||||
FS7Downloader
|
FS7Downloader,
|
||||||
|
ZoneTelechargementDownloader
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +68,3 @@ class GenericDownloader(BaseDownloader):
|
|||||||
# Just return the URL as-is
|
# Just return the URL as-is
|
||||||
filename = target_filename or url.split('/')[-1] or "download"
|
filename = target_filename or url.split('/')[-1] or "download"
|
||||||
return url, filename
|
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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,10 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
|
|
||||||
BASE_DOMAINS = ["anime-ultime.com", "anime-ultime.net", "www.anime-ultime.net"]
|
BASE_DOMAINS = ["anime-ultime.com", "anime-ultime.net", "www.anime-ultime.net"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.id = "anime-ultime"
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||||
|
|
||||||
@@ -24,58 +28,79 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
final_url = str(response.url)
|
final_url = str(response.url)
|
||||||
|
|
||||||
# Parse the page
|
# Parse the page
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
# Method 0: Look for og:video meta tag (most reliable for anime-ultime)
|
# Method 0: Look for og:video meta tag (most reliable for anime-ultime)
|
||||||
og_video = soup.find('meta', property='og:video')
|
og_video = soup.find("meta", property="og:video")
|
||||||
if og_video and og_video.get('content'):
|
if og_video and og_video.get("content"):
|
||||||
video_url = og_video['content']
|
video_url = og_video["content"]
|
||||||
if video_url.endswith('.mp4'):
|
if video_url.endswith(".mp4"):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
print(f"[ANIME-ULTIME] Found og:video link: {video_url}")
|
print(f"[ANIME-ULTIME] Found og:video link: {video_url}")
|
||||||
return video_url, filename
|
return video_url, filename
|
||||||
|
|
||||||
# Method 1: Look for direct download links (DDL)
|
# Method 1: Look for direct download links (DDL)
|
||||||
# Anime-Ultime often uses links to file hosts
|
# Anime-Ultime often uses links to file hosts
|
||||||
download_links = soup.find_all('a', href=True)
|
download_links = soup.find_all("a", href=True)
|
||||||
for link in download_links:
|
for link in download_links:
|
||||||
href = link['href']
|
href = link["href"]
|
||||||
text = link.get_text().lower()
|
text = link.get_text().lower()
|
||||||
|
|
||||||
# Look for download buttons/links
|
# Look for download buttons/links
|
||||||
if any(keyword in text for keyword in ['télécharger', 'download', 'ddl', 'mega', 'google', 'drive']):
|
if any(
|
||||||
|
keyword in text
|
||||||
|
for keyword in [
|
||||||
|
"télécharger",
|
||||||
|
"download",
|
||||||
|
"ddl",
|
||||||
|
"mega",
|
||||||
|
"google",
|
||||||
|
"drive",
|
||||||
|
]
|
||||||
|
):
|
||||||
# Check if it's a direct link or to a file host
|
# Check if it's a direct link or to a file host
|
||||||
if any(host in href.lower() for host in ['mega.nz', 'drive.google.com', 'uptobox.com', '1fichier.com']):
|
if any(
|
||||||
|
host in href.lower()
|
||||||
|
for host in [
|
||||||
|
"mega.nz",
|
||||||
|
"drive.google.com",
|
||||||
|
"uptobox.com",
|
||||||
|
"1fichier.com",
|
||||||
|
]
|
||||||
|
):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return href, filename
|
return href, filename
|
||||||
|
|
||||||
# Method 2: Look for iframe with video player
|
# Method 2: Look for iframe with video player
|
||||||
iframes = soup.find_all('iframe')
|
iframes = soup.find_all("iframe")
|
||||||
for iframe in iframes:
|
for iframe in iframes:
|
||||||
src = iframe.get('src', '')
|
src = iframe.get("src", "")
|
||||||
if src and any(provider in src for provider in ['video', 'player', 'stream', 'play']):
|
if src and any(
|
||||||
if src.startswith('http'):
|
provider in src
|
||||||
|
for provider in ["video", "player", "stream", "play"]
|
||||||
|
):
|
||||||
|
if src.startswith("http"):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 3: Look for video tags
|
# Method 3: Look for video tags
|
||||||
videos = soup.find_all('video')
|
videos = soup.find_all("video")
|
||||||
for video in videos:
|
for video in videos:
|
||||||
src = video.get('src', '')
|
src = video.get("src", "")
|
||||||
if src:
|
if src:
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Check source tags
|
# Check source tags
|
||||||
sources = video.find_all('source')
|
sources = video.find_all("source")
|
||||||
for source in sources:
|
for source in sources:
|
||||||
src = source.get('src', '')
|
src = source.get("src", "")
|
||||||
if src:
|
if src:
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 4: Look in scripts for video URLs
|
# Method 4: Look in scripts for video URLs
|
||||||
scripts = soup.find_all('script')
|
scripts = soup.find_all("script")
|
||||||
for script in scripts:
|
for script in scripts:
|
||||||
if script.string:
|
if script.string:
|
||||||
# Look for common video patterns
|
# Look for common video patterns
|
||||||
@@ -91,26 +116,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
matches = re.findall(pattern, script.string)
|
matches = re.findall(pattern, script.string)
|
||||||
for match in matches:
|
for match in matches:
|
||||||
# Clean up escaped characters
|
# Clean up escaped characters
|
||||||
match = match.replace('\\/', '/').replace('\\', '')
|
match = match.replace("\\/", "/").replace("\\", "")
|
||||||
if any(ext in match for ext in ['mp4', 'm3u8', 'mkv']):
|
if any(ext in match for ext in ["mp4", "m3u8", "mkv"]):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return match, filename
|
return match, filename
|
||||||
|
|
||||||
# Look for anime-ultime specific patterns
|
# Look for anime-ultime specific patterns
|
||||||
# They sometimes store links in JavaScript variables
|
# They sometimes store links in JavaScript variables
|
||||||
ddl_match = re.search(r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string)
|
ddl_match = re.search(
|
||||||
|
r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string
|
||||||
|
)
|
||||||
if ddl_match:
|
if ddl_match:
|
||||||
ddl_url = ddl_match.group(1)
|
ddl_url = ddl_match.group(1)
|
||||||
if ddl_url.startswith('http'):
|
if ddl_url.startswith("http"):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return ddl_url, filename
|
return ddl_url, filename
|
||||||
|
|
||||||
# Method 5: Look for links with specific classes or IDs
|
# Method 5: Look for links with specific classes or IDs
|
||||||
# Anime-Ultime might use specific class names for download links
|
# Anime-Ultime might use specific class names for download links
|
||||||
potential_links = soup.find_all('a', class_=re.compile(r'download|ddl|episode', re.I))
|
potential_links = soup.find_all(
|
||||||
|
"a", class_=re.compile(r"download|ddl|episode", re.I)
|
||||||
|
)
|
||||||
for link in potential_links:
|
for link in potential_links:
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
if href and href.startswith('http'):
|
if href and href.startswith("http"):
|
||||||
filename = self._generate_filename(final_url)
|
filename = self._generate_filename(final_url)
|
||||||
return href, filename
|
return href, filename
|
||||||
|
|
||||||
@@ -132,36 +161,38 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
episode = "01"
|
episode = "01"
|
||||||
|
|
||||||
# Format: info-0-1/EPISODE_ID or info-0-1/EPISODE_ID/NAME-EP-vostfr
|
# Format: info-0-1/EPISODE_ID or info-0-1/EPISODE_ID/NAME-EP-vostfr
|
||||||
if 'info-0-1/' in url:
|
if "info-0-1/" in url:
|
||||||
# Extract episode ID
|
# Extract episode ID
|
||||||
ep_match = re.search(r'info-0-1/(\d+)', url)
|
ep_match = re.search(r"info-0-1/(\d+)", url)
|
||||||
if ep_match:
|
if ep_match:
|
||||||
ep_id = ep_match.group(1)
|
ep_id = ep_match.group(1)
|
||||||
|
|
||||||
# Try to get anime name from URL path
|
# Try to get anime name from URL path
|
||||||
name_match = re.search(r'info-0-1/\d+/([^/]+)', url)
|
name_match = re.search(r"info-0-1/\d+/([^/]+)", url)
|
||||||
if name_match:
|
if name_match:
|
||||||
raw_name = name_match.group(1)
|
raw_name = name_match.group(1)
|
||||||
# Extract episode number
|
# Extract episode number
|
||||||
ep_num_match = re.search(r'-(\d+)-vostfr$', raw_name, re.I)
|
ep_num_match = re.search(r"-(\d+)-vostfr$", raw_name, re.I)
|
||||||
if ep_num_match:
|
if ep_num_match:
|
||||||
episode = ep_num_match.group(1).zfill(2)
|
episode = ep_num_match.group(1).zfill(2)
|
||||||
# Remove episode number and suffix from name
|
# Remove episode number and suffix from name
|
||||||
anime_name = re.sub(r'-\d+-vostfr$', '', raw_name, flags=re.I).replace('-', ' ')
|
anime_name = re.sub(
|
||||||
|
r"-\d+-vostfr$", "", raw_name, flags=re.I
|
||||||
|
).replace("-", " ")
|
||||||
else:
|
else:
|
||||||
# Just use the ID
|
# Just use the ID
|
||||||
anime_name = f"Episode {ep_id}"
|
anime_name = f"Episode {ep_id}"
|
||||||
else:
|
else:
|
||||||
anime_name = f"Episode {ep_id}"
|
anime_name = f"Episode {ep_id}"
|
||||||
|
|
||||||
elif 'file-0-1/' in url:
|
elif "file-0-1/" in url:
|
||||||
# Extract from file-0-1/ID-NAME format
|
# Extract from file-0-1/ID-NAME format
|
||||||
file_match = re.search(r'file-0-1/\d+-(.+)$', url)
|
file_match = re.search(r"file-0-1/\d+-(.+)$", url)
|
||||||
if file_match:
|
if file_match:
|
||||||
anime_name = file_match.group(1).replace('-', ' ')
|
anime_name = file_match.group(1).replace("-", " ")
|
||||||
|
|
||||||
# Sanitize filename
|
# Sanitize filename
|
||||||
anime_name = anime_name.replace('/', ' ').strip()
|
anime_name = anime_name.replace("/", " ").strip()
|
||||||
filename = f"{anime_name} - Episode {episode}.mp4"
|
filename = f"{anime_name} - Episode {episode}.mp4"
|
||||||
return filename.title()
|
return filename.title()
|
||||||
|
|
||||||
@@ -173,30 +204,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
try:
|
try:
|
||||||
print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}")
|
print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}")
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
'synopsis': None,
|
"synopsis": None,
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'rating': None,
|
"rating": None,
|
||||||
'release_year': None,
|
"release_year": None,
|
||||||
'studio': None,
|
"studio": None,
|
||||||
'poster_image': None,
|
"poster_image": None,
|
||||||
'banner_image': None,
|
"banner_image": None,
|
||||||
'total_episodes': None,
|
"total_episodes": None,
|
||||||
'status': None,
|
"status": None,
|
||||||
'alternative_titles': []
|
"alternative_titles": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract synopsis
|
# Extract synopsis
|
||||||
synopsis_selectors = [
|
synopsis_selectors = [
|
||||||
'div.synopsis',
|
"div.synopsis",
|
||||||
'div.description',
|
"div.description",
|
||||||
'div[class*="synopsis"]',
|
'div[class*="synopsis"]',
|
||||||
'div[class*="synopsis"]',
|
'div[class*="synopsis"]',
|
||||||
'p.synopsis',
|
"p.synopsis",
|
||||||
'.info',
|
".info",
|
||||||
'div.texte'
|
"div.texte",
|
||||||
]
|
]
|
||||||
|
|
||||||
for selector in synopsis_selectors:
|
for selector in synopsis_selectors:
|
||||||
@@ -204,68 +235,73 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
if synopsis_elem:
|
if synopsis_elem:
|
||||||
synopsis = synopsis_elem.get_text(strip=True)
|
synopsis = synopsis_elem.get_text(strip=True)
|
||||||
if len(synopsis) > 50:
|
if len(synopsis) > 50:
|
||||||
metadata['synopsis'] = synopsis
|
metadata["synopsis"] = synopsis
|
||||||
break
|
break
|
||||||
|
|
||||||
# Extract genres from meta tags and page content
|
# Extract genres from meta tags and page content
|
||||||
page_text = soup.get_text()
|
page_text = soup.get_text()
|
||||||
|
|
||||||
# Look for genre in meta tags
|
# Look for genre in meta tags
|
||||||
genre_meta = soup.find('meta', property='genre') or soup.find('meta', attrs={'name': 'genre'})
|
genre_meta = soup.find("meta", property="genre") or soup.find(
|
||||||
|
"meta", attrs={"name": "genre"}
|
||||||
|
)
|
||||||
if genre_meta:
|
if genre_meta:
|
||||||
genres_text = genre_meta.get('content', '')
|
genres_text = genre_meta.get("content", "")
|
||||||
if genres_text:
|
if genres_text:
|
||||||
metadata['genres'] = [g.strip() for g in genres_text.split(',')]
|
metadata["genres"] = [g.strip() for g in genres_text.split(",")]
|
||||||
|
|
||||||
# Try to find genre links
|
# Try to find genre links
|
||||||
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type|cat', re.I))
|
genre_links = soup.find_all(
|
||||||
|
"a", href=re.compile(r"genre|tag|type|cat", re.I)
|
||||||
|
)
|
||||||
if genre_links:
|
if genre_links:
|
||||||
for link in genre_links[:5]:
|
for link in genre_links[:5]:
|
||||||
genre = link.get_text(strip=True)
|
genre = link.get_text(strip=True)
|
||||||
if genre and genre not in metadata['genres']:
|
if genre and genre not in metadata["genres"]:
|
||||||
metadata['genres'].append(genre)
|
metadata["genres"].append(genre)
|
||||||
|
|
||||||
# Extract rating
|
# Extract rating
|
||||||
rating_selectors = [
|
rating_selectors = [
|
||||||
'span.rating',
|
"span.rating",
|
||||||
'div.rating',
|
"div.rating",
|
||||||
'span.score',
|
"span.score",
|
||||||
'div.note',
|
"div.note",
|
||||||
'.rating'
|
".rating",
|
||||||
]
|
]
|
||||||
|
|
||||||
for selector in rating_selectors:
|
for selector in rating_selectors:
|
||||||
rating_elem = soup.select_one(selector)
|
rating_elem = soup.select_one(selector)
|
||||||
if rating_elem:
|
if rating_elem:
|
||||||
rating_text = rating_elem.get_text(strip=True)
|
rating_text = rating_elem.get_text(strip=True)
|
||||||
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
|
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
|
||||||
if rating_match:
|
if rating_match:
|
||||||
metadata['rating'] = f"{rating_match.group(1)}/10"
|
metadata["rating"] = f"{rating_match.group(1)}/10"
|
||||||
break
|
break
|
||||||
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*5', rating_text)
|
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*5", rating_text)
|
||||||
if rating_match:
|
if rating_match:
|
||||||
rating_val = float(rating_match.group(1)) * 2
|
rating_val = float(rating_match.group(1)) * 2
|
||||||
metadata['rating'] = f"{rating_val:.1f}/10"
|
metadata["rating"] = f"{rating_val:.1f}/10"
|
||||||
break
|
break
|
||||||
|
|
||||||
# Extract release year
|
# Extract release year
|
||||||
year_match = re.search(r'\b(19\d{2}|20\d{2})\b', page_text)
|
year_match = re.search(r"\b(19\d{2}|20\d{2})\b", page_text)
|
||||||
if year_match:
|
if year_match:
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
current_year = datetime.datetime.now().year + 2
|
current_year = datetime.datetime.now().year + 2
|
||||||
year = int(year_match.group(1))
|
year = int(year_match.group(1))
|
||||||
if 1950 <= year <= current_year:
|
if 1950 <= year <= current_year:
|
||||||
metadata['release_year'] = year
|
metadata["release_year"] = year
|
||||||
|
|
||||||
# Extract poster image from og:image
|
# Extract poster image from og:image
|
||||||
og_image = soup.find('meta', property='og:image')
|
og_image = soup.find("meta", property="og:image")
|
||||||
if og_image:
|
if og_image:
|
||||||
metadata['poster_image'] = og_image.get('content')
|
metadata["poster_image"] = og_image.get("content")
|
||||||
|
|
||||||
# Extract total episodes
|
# Extract total episodes
|
||||||
episodes_count = len(await self.get_episodes(anime_url))
|
episodes_count = len(await self.get_episodes(anime_url))
|
||||||
if episodes_count > 0:
|
if episodes_count > 0:
|
||||||
metadata['total_episodes'] = episodes_count
|
metadata["total_episodes"] = episodes_count
|
||||||
|
|
||||||
print(f"[ANIME-ULTIME] Extracted metadata: {metadata}")
|
print(f"[ANIME-ULTIME] Extracted metadata: {metadata}")
|
||||||
return metadata
|
return metadata
|
||||||
@@ -274,7 +310,9 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
print(f"[ANIME-ULTIME] Error extracting metadata: {e}")
|
print(f"[ANIME-ULTIME] Error extracting metadata: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
async def search_anime(
|
||||||
|
self, query: str, lang: str = "vostfr", include_metadata: bool = False
|
||||||
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Search for anime on anime-ultime
|
Search for anime on anime-ultime
|
||||||
Returns list of anime with title, url, and cover image
|
Returns list of anime with title, url, and cover image
|
||||||
@@ -286,27 +324,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import time
|
import time
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...")
|
print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...")
|
||||||
|
|
||||||
# Anime-Ultime uses POST for search
|
# Anime-Ultime uses POST for search
|
||||||
search_url = "https://www.anime-ultime.net/search-0-1"
|
search_url = "https://www.anime-ultime.net/search-0-1"
|
||||||
|
|
||||||
response = await self.client.post(search_url, data={'search': query})
|
response = await self.client.post(search_url, data={"search": query})
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
print(f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s")
|
print(
|
||||||
|
f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s"
|
||||||
|
)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Look for search result links - better parsing
|
# Look for search result links - better parsing
|
||||||
# Search results use file-0-1/ pattern, not info-
|
# Search results use file-0-1/ pattern, not info-
|
||||||
search_results = soup.find_all('a', href=re.compile(r'file-0-1/'))
|
search_results = soup.find_all("a", href=re.compile(r"file-0-1/"))
|
||||||
|
|
||||||
seen_urls = set()
|
seen_urls = set()
|
||||||
for result in search_results[:10]: # Limit to 10 results
|
for result in search_results[:10]: # Limit to 10 results
|
||||||
href = result.get('href', '')
|
href = result.get("href", "")
|
||||||
raw_title = result.get_text().strip()
|
raw_title = result.get_text().strip()
|
||||||
|
|
||||||
# Skip if no href
|
# Skip if no href
|
||||||
@@ -322,40 +363,44 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
better_title = raw_title
|
better_title = raw_title
|
||||||
|
|
||||||
# If raw_title is just "Télécharger" or similar, try to find better title
|
# If raw_title is just "Télécharger" or similar, try to find better title
|
||||||
if len(raw_title) < 5 or raw_title.lower() in ['télécharger', 'download', 'ddl']:
|
if len(raw_title) < 5 or raw_title.lower() in [
|
||||||
|
"télécharger",
|
||||||
|
"download",
|
||||||
|
"ddl",
|
||||||
|
]:
|
||||||
# Try to extract from URL (file-0-1/ID-Title format)
|
# Try to extract from URL (file-0-1/ID-Title format)
|
||||||
url_match = re.search(r'file-0-1/\d+-(.+)$', href)
|
url_match = re.search(r"file-0-1/\d+-(.+)$", href)
|
||||||
if url_match:
|
if url_match:
|
||||||
better_title = url_match.group(1).replace('-', ' ').title()
|
better_title = url_match.group(1).replace("-", " ").title()
|
||||||
|
|
||||||
# If still no good title, look at parent/row elements
|
# If still no good title, look at parent/row elements
|
||||||
if len(better_title) < 5:
|
if len(better_title) < 5:
|
||||||
# Check parent row (table structure)
|
# Check parent row (table structure)
|
||||||
row = result.find_parent(['tr', 'td', 'div'])
|
row = result.find_parent(["tr", "td", "div"])
|
||||||
if row:
|
if row:
|
||||||
# Look for text in the row that's not the link text
|
# Look for text in the row that's not the link text
|
||||||
row_text = row.get_text().strip()
|
row_text = row.get_text().strip()
|
||||||
# Remove the link text from row text
|
# Remove the link text from row text
|
||||||
if raw_title in row_text:
|
if raw_title in row_text:
|
||||||
row_text = row_text.replace(raw_title, '').strip()
|
row_text = row_text.replace(raw_title, "").strip()
|
||||||
if len(row_text) > 5 and len(row_text) < 100:
|
if len(row_text) > 5 and len(row_text) < 100:
|
||||||
better_title = row_text
|
better_title = row_text
|
||||||
|
|
||||||
# Make URL absolute
|
# Make URL absolute
|
||||||
if not href.startswith('http'):
|
if not href.startswith("http"):
|
||||||
href = urljoin("https://www.anime-ultime.net/", href)
|
href = urljoin("https://www.anime-ultime.net/", href)
|
||||||
|
|
||||||
result_item = {
|
result_item = {
|
||||||
'title': better_title,
|
"title": better_title,
|
||||||
'url': href,
|
"url": href,
|
||||||
'type': 'search_result',
|
"type": "search_result",
|
||||||
'metadata': None
|
"metadata": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fetch metadata if requested
|
# Fetch metadata if requested
|
||||||
if include_metadata:
|
if include_metadata:
|
||||||
metadata = await self.get_anime_metadata(href)
|
metadata = await self.get_anime_metadata(href)
|
||||||
result_item['metadata'] = metadata
|
result_item["metadata"] = metadata
|
||||||
|
|
||||||
results.append(result_item)
|
results.append(result_item)
|
||||||
|
|
||||||
@@ -373,27 +418,27 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
episodes = []
|
episodes = []
|
||||||
|
|
||||||
# Look for episode links - anime-ultime uses info-XXXXX-Name-XX-vostfr format
|
# Look for episode links - anime-ultime uses info-XXXXX-Name-XX-vostfr format
|
||||||
# The URL pattern is info-0-1/ID-Anime-Name-XX-vostfr where XX is episode number
|
# The URL pattern is info-0-1/ID-Anime-Name-XX-vostfr where XX is episode number
|
||||||
episode_links = soup.find_all('a', href=re.compile(r'info-0-1/\d+'))
|
episode_links = soup.find_all("a", href=re.compile(r"info-0-1/\d+"))
|
||||||
|
|
||||||
for link in episode_links:
|
for link in episode_links:
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
text = link.get_text().strip()
|
text = link.get_text().strip()
|
||||||
|
|
||||||
# Extract episode number from URL pattern
|
# Extract episode number from URL pattern
|
||||||
# Matches: info-0-1/30200/Naruto-OAV-01-vostfr
|
# Matches: info-0-1/30200/Naruto-OAV-01-vostfr
|
||||||
match = re.search(r'-(\d+)-vostfr$', href, re.I)
|
match = re.search(r"-(\d+)-vostfr$", href, re.I)
|
||||||
if not match:
|
if not match:
|
||||||
# Try other patterns
|
# Try other patterns
|
||||||
match = re.search(r'Episode[-\s]?(\d+)', href, re.I)
|
match = re.search(r"Episode[-\s]?(\d+)", href, re.I)
|
||||||
if not match:
|
if not match:
|
||||||
# Try to extract from text
|
# Try to extract from text
|
||||||
match = re.search(r'(\d+)', text)
|
match = re.search(r"(\d+)", text)
|
||||||
|
|
||||||
if match:
|
if match:
|
||||||
episode_num = match.group(1).zfill(2) # Pad with zero
|
episode_num = match.group(1).zfill(2) # Pad with zero
|
||||||
@@ -401,32 +446,30 @@ class AnimeUltimeDownloader(BaseAnimeSite):
|
|||||||
# Extract the episode ID from href and build correct URL
|
# Extract the episode ID from href and build correct URL
|
||||||
# href might be "info-0-1/30200" or "info-0-1/30200/..."
|
# href might be "info-0-1/30200" or "info-0-1/30200/..."
|
||||||
# We need: https://www.anime-ultime.net/info-0-1/30200
|
# We need: https://www.anime-ultime.net/info-0-1/30200
|
||||||
ep_id_match = re.search(r'info-0-1/(\d+)', href)
|
ep_id_match = re.search(r"info-0-1/(\d+)", href)
|
||||||
if ep_id_match:
|
if ep_id_match:
|
||||||
ep_id = ep_id_match.group(1)
|
ep_id = ep_id_match.group(1)
|
||||||
# Build the correct episode URL
|
# Build the correct episode URL
|
||||||
episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}"
|
episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}"
|
||||||
else:
|
else:
|
||||||
# Fallback to making URL absolute
|
# Fallback to making URL absolute
|
||||||
if not href.startswith('http'):
|
if not href.startswith("http"):
|
||||||
href = urljoin(anime_url, href)
|
href = urljoin(anime_url, href)
|
||||||
episode_url = href
|
episode_url = href
|
||||||
|
|
||||||
episodes.append({
|
episodes.append(
|
||||||
'episode': episode_num,
|
{"episode": episode_num, "url": episode_url, "title": text}
|
||||||
'url': episode_url,
|
)
|
||||||
'title': text
|
|
||||||
})
|
|
||||||
|
|
||||||
# Remove duplicates and sort
|
# Remove duplicates and sort
|
||||||
seen = set()
|
seen = set()
|
||||||
unique_episodes = []
|
unique_episodes = []
|
||||||
for ep in episodes:
|
for ep in episodes:
|
||||||
if ep['episode'] not in seen:
|
if ep["episode"] not in seen:
|
||||||
seen.add(ep['episode'])
|
seen.add(ep["episode"])
|
||||||
unique_episodes.append(ep)
|
unique_episodes.append(ep)
|
||||||
|
|
||||||
unique_episodes.sort(key=lambda x: int(x['episode']))
|
unique_episodes.sort(key=lambda x: int(x["episode"]))
|
||||||
|
|
||||||
return unique_episodes
|
return unique_episodes
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""French-Manga.net anime streaming site downloader"""
|
"""French-Manga.net anime streaming site downloader"""
|
||||||
|
|
||||||
from .base import BaseAnimeSite
|
from .base import BaseAnimeSite
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
import re
|
import re
|
||||||
@@ -17,11 +18,12 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
"french-manga.net",
|
"french-manga.net",
|
||||||
"w16.french-manga.net",
|
"w16.french-manga.net",
|
||||||
"w15.french-manga.net",
|
"w15.french-manga.net",
|
||||||
"www.french-manga.net"
|
"www.french-manga.net",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.id = "french-manga"
|
||||||
self.base_url = "https://w16.french-manga.net"
|
self.base_url = "https://w16.french-manga.net"
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
@@ -29,9 +31,7 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||||
|
|
||||||
async def search_anime(
|
async def search_anime(
|
||||||
self,
|
self, query: str, lang: str = "vostfr"
|
||||||
query: str,
|
|
||||||
lang: str = "vostfr"
|
|
||||||
) -> List[Dict[str, str]]:
|
) -> List[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Search for anime on French-Manga.
|
Search for anime on French-Manga.
|
||||||
@@ -47,46 +47,50 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
# French-Manga uses a search endpoint
|
# French-Manga uses a search endpoint
|
||||||
search_url = f"{self.base_url}/index.php?do=search"
|
search_url = f"{self.base_url}/index.php?do=search"
|
||||||
params = {
|
params = {
|
||||||
'do': 'search',
|
"do": "search",
|
||||||
'subaction': 'search',
|
"subaction": "search",
|
||||||
'story': query,
|
"story": query,
|
||||||
'x': '0',
|
"x": "0",
|
||||||
'y': '0'
|
"y": "0",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = await self.client.post(search_url, data=params)
|
response = await self.client.post(search_url, data=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Look for search results in article or story classes
|
# Look for search results in article or story classes
|
||||||
for item in soup.find_all('article', class_=lambda x: x and 'story' in x.lower()):
|
for item in soup.find_all(
|
||||||
title_elem = item.find(['h2', 'h3', 'h4'])
|
"article", class_=lambda x: x and "story" in x.lower()
|
||||||
link_elem = item.find('a', href=True)
|
):
|
||||||
img_elem = item.find('img')
|
title_elem = item.find(["h2", "h3", "h4"])
|
||||||
|
link_elem = item.find("a", href=True)
|
||||||
|
img_elem = item.find("img")
|
||||||
|
|
||||||
if title_elem and link_elem:
|
if title_elem and link_elem:
|
||||||
title = title_elem.get_text(strip=True)
|
title = title_elem.get_text(strip=True)
|
||||||
url = link_elem['href']
|
url = link_elem["href"]
|
||||||
|
|
||||||
# Ensure absolute URL
|
# Ensure absolute URL
|
||||||
if url.startswith('/'):
|
if url.startswith("/"):
|
||||||
url = self.base_url + url
|
url = self.base_url + url
|
||||||
|
|
||||||
cover_image = ""
|
cover_image = ""
|
||||||
if img_elem and img_elem.get('src'):
|
if img_elem and img_elem.get("src"):
|
||||||
cover_image = img_elem['src']
|
cover_image = img_elem["src"]
|
||||||
if cover_image.startswith('/'):
|
if cover_image.startswith("/"):
|
||||||
cover_image = self.base_url + cover_image
|
cover_image = self.base_url + cover_image
|
||||||
|
|
||||||
results.append({
|
results.append(
|
||||||
'title': title,
|
{
|
||||||
'url': url,
|
"title": title,
|
||||||
'cover_image': cover_image,
|
"url": url,
|
||||||
'lang': lang
|
"cover_image": cover_image,
|
||||||
})
|
"lang": lang,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Found {len(results)} anime results for query: {query}")
|
logger.info(f"Found {len(results)} anime results for query: {query}")
|
||||||
return results
|
return results
|
||||||
@@ -96,9 +100,7 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_episodes(
|
async def get_episodes(
|
||||||
self,
|
self, anime_url: str, lang: str = "vostfr"
|
||||||
anime_url: str,
|
|
||||||
lang: str = "vostfr"
|
|
||||||
) -> List[Dict[str, str]]:
|
) -> List[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Get episode list for an anime.
|
Get episode list for an anime.
|
||||||
@@ -115,34 +117,36 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
episodes = []
|
episodes = []
|
||||||
|
|
||||||
# Look for episode links (typically in a list or table)
|
# Look for episode links (typically in a list or table)
|
||||||
# French-Manga usually has episode links in <a> tags with episode numbers
|
# French-Manga usually has episode links in <a> tags with episode numbers
|
||||||
for link in soup.find_all('a', href=True):
|
for link in soup.find_all("a", href=True):
|
||||||
href = link['href']
|
href = link["href"]
|
||||||
text = link.get_text(strip=True)
|
text = link.get_text(strip=True)
|
||||||
|
|
||||||
# Pattern: Episode links usually contain "episode" or numbers
|
# Pattern: Episode links usually contain "episode" or numbers
|
||||||
if re.search(r'episode?\s*\d+', text.lower()):
|
if re.search(r"episode?\s*\d+", text.lower()):
|
||||||
episode_num = re.search(r'(\d+)', text)
|
episode_num = re.search(r"(\d+)", text)
|
||||||
if episode_num:
|
if episode_num:
|
||||||
episode_number = int(episode_num.group(1))
|
episode_number = int(episode_num.group(1))
|
||||||
|
|
||||||
# Ensure absolute URL
|
# Ensure absolute URL
|
||||||
if href.startswith('/'):
|
if href.startswith("/"):
|
||||||
href = self.base_url + href
|
href = self.base_url + href
|
||||||
|
|
||||||
episodes.append({
|
episodes.append(
|
||||||
'episode_number': episode_number,
|
{
|
||||||
'url': href,
|
"episode_number": episode_number,
|
||||||
'title': text,
|
"url": href,
|
||||||
'host': 'french-manga'
|
"title": text,
|
||||||
})
|
"host": "french-manga",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Sort by episode number
|
# Sort by episode number
|
||||||
episodes.sort(key=lambda x: x['episode_number'])
|
episodes.sort(key=lambda x: x["episode_number"])
|
||||||
|
|
||||||
logger.info(f"Found {len(episodes)} episodes for {anime_url}")
|
logger.info(f"Found {len(episodes)} episodes for {anime_url}")
|
||||||
return episodes
|
return episodes
|
||||||
@@ -166,31 +170,33 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
# Extract title
|
# Extract title
|
||||||
title = ""
|
title = ""
|
||||||
title_elem = soup.find('h1') or soup.find('h2', class_='title')
|
title_elem = soup.find("h1") or soup.find("h2", class_="title")
|
||||||
if title_elem:
|
if title_elem:
|
||||||
title = title_elem.get_text(strip=True)
|
title = title_elem.get_text(strip=True)
|
||||||
|
|
||||||
# Extract synopsis
|
# Extract synopsis
|
||||||
synopsis = ""
|
synopsis = ""
|
||||||
synopsis_elem = soup.find('div', class_=lambda x: x and 'story' in x.lower())
|
synopsis_elem = soup.find(
|
||||||
|
"div", class_=lambda x: x and "story" in x.lower()
|
||||||
|
)
|
||||||
if synopsis_elem:
|
if synopsis_elem:
|
||||||
synopsis = synopsis_elem.get_text(strip=True)
|
synopsis = synopsis_elem.get_text(strip=True)
|
||||||
|
|
||||||
# Extract cover image
|
# Extract cover image
|
||||||
poster_image = ""
|
poster_image = ""
|
||||||
img_elem = soup.find('img', class_=lambda x: x and 'poster' in x.lower())
|
img_elem = soup.find("img", class_=lambda x: x and "poster" in x.lower())
|
||||||
if img_elem and img_elem.get('src'):
|
if img_elem and img_elem.get("src"):
|
||||||
poster_image = img_elem['src']
|
poster_image = img_elem["src"]
|
||||||
if poster_image.startswith('/'):
|
if poster_image.startswith("/"):
|
||||||
poster_image = self.base_url + poster_image
|
poster_image = self.base_url + poster_image
|
||||||
|
|
||||||
# Extract genres
|
# Extract genres
|
||||||
genres = []
|
genres = []
|
||||||
genre_links = soup.find_all('a', href=re.compile(r'/xfsearch/.*genre/'))
|
genre_links = soup.find_all("a", href=re.compile(r"/xfsearch/.*genre/"))
|
||||||
for link in genre_links[:10]: # Limit to 10 genres
|
for link in genre_links[:10]: # Limit to 10 genres
|
||||||
genre = link.get_text(strip=True)
|
genre = link.get_text(strip=True)
|
||||||
if genre:
|
if genre:
|
||||||
@@ -198,36 +204,38 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
|
|
||||||
# Extract rating (if available)
|
# Extract rating (if available)
|
||||||
rating = ""
|
rating = ""
|
||||||
rating_elem = soup.find(['span', 'div'], class_=lambda x: x and 'rating' in x.lower())
|
rating_elem = soup.find(
|
||||||
|
["span", "div"], class_=lambda x: x and "rating" in x.lower()
|
||||||
|
)
|
||||||
if rating_elem:
|
if rating_elem:
|
||||||
rating = rating_elem.get_text(strip=True)
|
rating = rating_elem.get_text(strip=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'title': title,
|
"title": title,
|
||||||
'synopsis': synopsis,
|
"synopsis": synopsis,
|
||||||
'genres': genres,
|
"genres": genres,
|
||||||
'rating': rating,
|
"rating": rating,
|
||||||
'release_year': '',
|
"release_year": "",
|
||||||
'studio': '',
|
"studio": "",
|
||||||
'poster_image': poster_image,
|
"poster_image": poster_image,
|
||||||
'total_episodes': len(await self.get_episodes(anime_url)),
|
"total_episodes": len(await self.get_episodes(anime_url)),
|
||||||
'status': '',
|
"status": "",
|
||||||
'languages': ['vf', 'vostfr']
|
"languages": ["vf", "vostfr"],
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting anime metadata: {e}")
|
logger.error(f"Error getting anime metadata: {e}")
|
||||||
return {
|
return {
|
||||||
'title': '',
|
"title": "",
|
||||||
'synopsis': '',
|
"synopsis": "",
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'rating': '',
|
"rating": "",
|
||||||
'release_year': '',
|
"release_year": "",
|
||||||
'studio': '',
|
"studio": "",
|
||||||
'poster_image': '',
|
"poster_image": "",
|
||||||
'total_episodes': 0,
|
"total_episodes": 0,
|
||||||
'status': '',
|
"status": "",
|
||||||
'languages': ['vf', 'vostfr']
|
"languages": ["vf", "vostfr"],
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_download_link(self, url: str) -> tuple[str, str]:
|
async def get_download_link(self, url: str) -> tuple[str, str]:
|
||||||
@@ -248,20 +256,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
# Look for iframe or video player
|
# Look for iframe or video player
|
||||||
iframe = soup.find('iframe', src=True)
|
iframe = soup.find("iframe", src=True)
|
||||||
if iframe:
|
if iframe:
|
||||||
video_url = iframe['src']
|
video_url = iframe["src"]
|
||||||
else:
|
else:
|
||||||
# Look for video tag directly
|
# Look for video tag directly
|
||||||
video = soup.find('video', src=True)
|
video = soup.find("video", src=True)
|
||||||
if video:
|
if video:
|
||||||
video_url = video['src']
|
video_url = video["src"]
|
||||||
else:
|
else:
|
||||||
# Try to find in script tags
|
# Try to find in script tags
|
||||||
scripts = soup.find_all('script')
|
scripts = soup.find_all("script")
|
||||||
for script in scripts:
|
for script in scripts:
|
||||||
if script.string:
|
if script.string:
|
||||||
# Look for iframe or video URLs in JavaScript
|
# Look for iframe or video URLs in JavaScript
|
||||||
@@ -274,20 +282,20 @@ class FrenchMangaDownloader(BaseAnimeSite):
|
|||||||
if match:
|
if match:
|
||||||
video_url = match.group(1)
|
video_url = match.group(1)
|
||||||
break
|
break
|
||||||
if 'video_url' in locals():
|
if "video_url" in locals():
|
||||||
break
|
break
|
||||||
|
|
||||||
if 'video_url' not in locals():
|
if "video_url" not in locals():
|
||||||
raise ValueError("Could not find video player URL")
|
raise ValueError("Could not find video player URL")
|
||||||
|
|
||||||
# Ensure absolute URL
|
# Ensure absolute URL
|
||||||
if video_url.startswith('//'):
|
if video_url.startswith("//"):
|
||||||
video_url = 'https:' + video_url
|
video_url = "https:" + video_url
|
||||||
elif video_url.startswith('/'):
|
elif video_url.startswith("/"):
|
||||||
video_url = self.base_url + video_url
|
video_url = self.base_url + video_url
|
||||||
|
|
||||||
# Extract episode title
|
# Extract episode title
|
||||||
title_elem = soup.find('h1') or soup.find('h2')
|
title_elem = soup.find("h1") or soup.find("h2")
|
||||||
episode_title = title_elem.get_text(strip=True) if title_elem else "Episode"
|
episode_title = title_elem.get_text(strip=True) if title_elem else "Episode"
|
||||||
episode_title = sanitize_filename(episode_title)
|
episode_title = sanitize_filename(episode_title)
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,25 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
This provider can search and get metadata but cannot provide direct download links.
|
This provider can search and get metadata but cannot provide direct download links.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BASE_DOMAINS = ["neko-sama.org", "www.neko-sama.org", "neko-sama.fr", "nekosama.fr", "www.gupy.fr", "gupy.fr"]
|
BASE_DOMAINS = [
|
||||||
|
"neko-sama.org",
|
||||||
|
"www.neko-sama.org",
|
||||||
|
"neko-sama.fr",
|
||||||
|
"nekosama.fr",
|
||||||
|
"www.gupy.fr",
|
||||||
|
"gupy.fr",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.id = "neko-sama"
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||||
|
|
||||||
async def get_download_link(self, url: str, target_filename: Optional[str] = None) -> tuple[str, str]:
|
async def get_download_link(
|
||||||
|
self, url: str, target_filename: Optional[str] = None
|
||||||
|
) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Extract download link from neko-sama URL.
|
Extract download link from neko-sama URL.
|
||||||
|
|
||||||
@@ -27,59 +40,67 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if this is a Gupy URL
|
# Check if this is a Gupy URL
|
||||||
if 'gupy.fr' in url or 'neko-sama.org' in url:
|
if "gupy.fr" in url or "neko-sama.org" in url:
|
||||||
response = await self.client.get(url, follow_redirects=True)
|
response = await self.client.get(url, follow_redirects=True)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
# Look for streaming platform links
|
# Look for streaming platform links
|
||||||
streaming_links = []
|
streaming_links = []
|
||||||
for link in soup.find_all('a', href=True):
|
for link in soup.find_all("a", href=True):
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
if '/out/' in href:
|
if "/out/" in href:
|
||||||
text = link.get_text(strip=True)
|
text = link.get_text(strip=True)
|
||||||
if text and 'Regarder' in text:
|
if text and "Regarder" in text:
|
||||||
streaming_links.append(f"{text}: {href}")
|
streaming_links.append(f"{text}: {href}")
|
||||||
|
|
||||||
if streaming_links:
|
if streaming_links:
|
||||||
title_elem = soup.find('h1') or soup.find('title')
|
title_elem = soup.find("h1") or soup.find("title")
|
||||||
title = title_elem.get_text(strip=True).split('|')[0].strip() if title_elem else "Unknown"
|
title = (
|
||||||
info = "Available streaming platforms:\n" + "\n".join(streaming_links[:5])
|
title_elem.get_text(strip=True).split("|")[0].strip()
|
||||||
|
if title_elem
|
||||||
|
else "Unknown"
|
||||||
|
)
|
||||||
|
info = "Available streaming platforms:\n" + "\n".join(
|
||||||
|
streaming_links[:5]
|
||||||
|
)
|
||||||
filename = target_filename or f"{title}_streaming_info.txt"
|
filename = target_filename or f"{title}_streaming_info.txt"
|
||||||
return info, filename
|
return info, filename
|
||||||
|
|
||||||
raise Exception("No streaming links found - Gupy is a legal streaming search, not a video host")
|
raise Exception(
|
||||||
|
"No streaming links found - Gupy is a legal streaming search, not a video host"
|
||||||
|
)
|
||||||
|
|
||||||
# Legacy: try original method for other URLs
|
# Legacy: try original method for other URLs
|
||||||
response = await self.client.get(url, follow_redirects=True)
|
response = await self.client.get(url, follow_redirects=True)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
# Method 1: Look for iframes with video
|
# Method 1: Look for iframes with video
|
||||||
iframes = soup.find_all('iframe')
|
iframes = soup.find_all("iframe")
|
||||||
for iframe in iframes:
|
for iframe in iframes:
|
||||||
src = iframe.get('src', '')
|
src = iframe.get("src", "")
|
||||||
if src and any(p in src for p in ['video', 'player', 'stream']):
|
if src and any(p in src for p in ["video", "player", "stream"]):
|
||||||
if not src.startswith('http'):
|
if not src.startswith("http"):
|
||||||
src = urljoin(str(response.url), src)
|
src = urljoin(str(response.url), src)
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 2: Look for video tags
|
# Method 2: Look for video tags
|
||||||
videos = soup.find_all('video')
|
videos = soup.find_all("video")
|
||||||
for video in videos:
|
for video in videos:
|
||||||
src = video.get('src') or video.get('data-src')
|
src = video.get("src") or video.get("data-src")
|
||||||
if src:
|
if src:
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
sources = video.find_all('source')
|
sources = video.find_all("source")
|
||||||
for source in sources:
|
for source in sources:
|
||||||
src = source.get('src', '')
|
src = source.get("src", "")
|
||||||
if src:
|
if src:
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 3: Look in scripts
|
# Method 3: Look in scripts
|
||||||
scripts = soup.find_all('script')
|
scripts = soup.find_all("script")
|
||||||
for script in scripts:
|
for script in scripts:
|
||||||
if script.string:
|
if script.string:
|
||||||
patterns = [
|
patterns = [
|
||||||
@@ -90,24 +111,26 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
matches = re.findall(pattern, script.string)
|
matches = re.findall(pattern, script.string)
|
||||||
for match in matches:
|
for match in matches:
|
||||||
match = match.replace('\\/', '/')
|
match = match.replace("\\/", "/")
|
||||||
if any(ext in match for ext in ['mp4', 'm3u8']):
|
if any(ext in match for ext in ["mp4", "m3u8"]):
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return match, filename
|
return match, filename
|
||||||
|
|
||||||
raise Exception("Could not find video link - Neko-Sama/Gupy does not host video content")
|
raise Exception(
|
||||||
|
"Could not find video link - Neko-Sama/Gupy does not host video content"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error extracting NekoSama link: {str(e)}")
|
raise Exception(f"Error extracting NekoSama link: {str(e)}")
|
||||||
|
|
||||||
def _generate_filename(self, url: str) -> str:
|
def _generate_filename(self, url: str) -> str:
|
||||||
parts = url.split('/')
|
parts = url.split("/")
|
||||||
anime_name = "anime"
|
anime_name = "anime"
|
||||||
episode = "1"
|
episode = "1"
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
if 'episode' in part.lower():
|
if "episode" in part.lower():
|
||||||
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
|
match = re.search(r"episode[-\s]*(\d+)", part, re.I)
|
||||||
if match:
|
if match:
|
||||||
episode = match.group(1)
|
episode = match.group(1)
|
||||||
|
|
||||||
@@ -118,31 +141,31 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
"""Get list of episodes for an anime."""
|
"""Get list of episodes for an anime."""
|
||||||
try:
|
try:
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
episodes = []
|
episodes = []
|
||||||
# Try to find episode links
|
# Try to find episode links
|
||||||
episode_links = soup.find_all('a', href=re.compile(r'episode'))
|
episode_links = soup.find_all("a", href=re.compile(r"episode"))
|
||||||
|
|
||||||
for link in episode_links:
|
for link in episode_links:
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
|
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
|
||||||
if match:
|
if match:
|
||||||
episode_num = match.group(1)
|
episode_num = match.group(1)
|
||||||
if not href.startswith('http'):
|
if not href.startswith("http"):
|
||||||
href = urljoin(anime_url, href)
|
href = urljoin(anime_url, href)
|
||||||
|
|
||||||
episodes.append({'episode': episode_num, 'url': href})
|
episodes.append({"episode": episode_num, "url": href})
|
||||||
|
|
||||||
# Deduplicate and sort
|
# Deduplicate and sort
|
||||||
seen = set()
|
seen = set()
|
||||||
unique_episodes = []
|
unique_episodes = []
|
||||||
for ep in episodes:
|
for ep in episodes:
|
||||||
if ep['episode'] not in seen:
|
if ep["episode"] not in seen:
|
||||||
seen.add(ep['episode'])
|
seen.add(ep["episode"])
|
||||||
unique_episodes.append(ep)
|
unique_episodes.append(ep)
|
||||||
|
|
||||||
unique_episodes.sort(key=lambda x: int(x['episode']))
|
unique_episodes.sort(key=lambda x: int(x["episode"]))
|
||||||
return unique_episodes
|
return unique_episodes
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -153,70 +176,70 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
try:
|
try:
|
||||||
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
|
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
'synopsis': None,
|
"synopsis": None,
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'rating': None,
|
"rating": None,
|
||||||
'release_year': None,
|
"release_year": None,
|
||||||
'studio': None,
|
"studio": None,
|
||||||
'poster_image': None,
|
"poster_image": None,
|
||||||
'banner_image': None,
|
"banner_image": None,
|
||||||
'total_episodes': None,
|
"total_episodes": None,
|
||||||
'status': None,
|
"status": None,
|
||||||
'alternative_titles': []
|
"alternative_titles": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract title and year from h1
|
# Extract title and year from h1
|
||||||
title_elem = soup.find('h1')
|
title_elem = soup.find("h1")
|
||||||
if title_elem:
|
if title_elem:
|
||||||
title_text = title_elem.get_text(strip=True)
|
title_text = title_elem.get_text(strip=True)
|
||||||
# Extract year from title like "Naruto (2002)"
|
# Extract year from title like "Naruto (2002)"
|
||||||
year_match = re.search(r'\((\d{4})\)', title_text)
|
year_match = re.search(r"\((\d{4})\)", title_text)
|
||||||
if year_match:
|
if year_match:
|
||||||
metadata['release_year'] = int(year_match.group(1))
|
metadata["release_year"] = int(year_match.group(1))
|
||||||
|
|
||||||
# Extract synopsis - Gupy shows it as paragraphs
|
# Extract synopsis - Gupy shows it as paragraphs
|
||||||
synopsis_elem = soup.find('p')
|
synopsis_elem = soup.find("p")
|
||||||
if synopsis_elem:
|
if synopsis_elem:
|
||||||
text = synopsis_elem.get_text(strip=True)
|
text = synopsis_elem.get_text(strip=True)
|
||||||
if len(text) > 50:
|
if len(text) > 50:
|
||||||
metadata['synopsis'] = text
|
metadata["synopsis"] = text
|
||||||
|
|
||||||
# Extract genres from meta tags or links
|
# Extract genres from meta tags or links
|
||||||
genre_links = soup.find_all('a', href=re.compile(r'serie-|genre|tag'))
|
genre_links = soup.find_all("a", href=re.compile(r"serie-|genre|tag"))
|
||||||
if genre_links:
|
if genre_links:
|
||||||
genres = []
|
genres = []
|
||||||
for link in genre_links[:5]:
|
for link in genre_links[:5]:
|
||||||
text = link.get_text(strip=True)
|
text = link.get_text(strip=True)
|
||||||
if text and '/' not in text and len(text) < 30:
|
if text and "/" not in text and len(text) < 30:
|
||||||
genres.append(text)
|
genres.append(text)
|
||||||
metadata['genres'] = genres
|
metadata["genres"] = genres
|
||||||
|
|
||||||
# Extract rating from percentage
|
# Extract rating from percentage
|
||||||
rating_elem = soup.find(string=re.compile(r'\d+(\.\d+)?%'))
|
rating_elem = soup.find(string=re.compile(r"\d+(\.\d+)?%"))
|
||||||
if rating_elem:
|
if rating_elem:
|
||||||
match = re.search(r'(\d+(\.\d+)?)%', rating_elem)
|
match = re.search(r"(\d+(\.\d+)?)%", rating_elem)
|
||||||
if match:
|
if match:
|
||||||
rating = float(match.group(1)) / 10
|
rating = float(match.group(1)) / 10
|
||||||
metadata['rating'] = f"{rating:.1f}/10"
|
metadata["rating"] = f"{rating:.1f}/10"
|
||||||
|
|
||||||
# Extract poster image
|
# Extract poster image
|
||||||
poster_elem = soup.find('img', src=re.compile(r'poster|poster'))
|
poster_elem = soup.find("img", src=re.compile(r"poster|poster"))
|
||||||
if poster_elem:
|
if poster_elem:
|
||||||
metadata['poster_image'] = poster_elem.get('src')
|
metadata["poster_image"] = poster_elem.get("src")
|
||||||
|
|
||||||
# Extract episode count from page text
|
# Extract episode count from page text
|
||||||
page_text = soup.get_text()
|
page_text = soup.get_text()
|
||||||
ep_match = re.search(r'(\d+)\s*episodes?', page_text, re.I)
|
ep_match = re.search(r"(\d+)\s*episodes?", page_text, re.I)
|
||||||
if ep_match:
|
if ep_match:
|
||||||
metadata['total_episodes'] = int(ep_match.group(1))
|
metadata["total_episodes"] = int(ep_match.group(1))
|
||||||
|
|
||||||
# Extract studio/director
|
# Extract studio/director
|
||||||
director_elem = soup.find('a', href=re.compile(r'person|réalisé'))
|
director_elem = soup.find("a", href=re.compile(r"person|réalisé"))
|
||||||
if director_elem:
|
if director_elem:
|
||||||
metadata['studio'] = director_elem.get_text(strip=True)
|
metadata["studio"] = director_elem.get_text(strip=True)
|
||||||
|
|
||||||
print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
|
print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
|
||||||
return metadata
|
return metadata
|
||||||
@@ -225,16 +248,19 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
print(f"[NEKO-SAMA] Error extracting metadata: {e}")
|
print(f"[NEKO-SAMA] Error extracting metadata: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
async def search_anime(
|
||||||
|
self, query: str, lang: str = "vostfr", include_metadata: bool = False
|
||||||
|
) -> list[dict]:
|
||||||
"""Search for anime on neko-sama (uses Gupy backend)."""
|
"""Search for anime on neko-sama (uses Gupy backend)."""
|
||||||
try:
|
try:
|
||||||
import time
|
import time
|
||||||
from html import unescape
|
from html import unescape
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
|
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
|
||||||
|
|
||||||
# Neko-Sama now uses Gupy - try the direct URL pattern
|
# Neko-Sama now uses Gupy - try the direct URL pattern
|
||||||
search_slug = query.lower().replace(' ', '-')
|
search_slug = query.lower().replace(" ", "-")
|
||||||
search_urls = [
|
search_urls = [
|
||||||
f"https://www.gupy.fr/series/{search_slug}/",
|
f"https://www.gupy.fr/series/{search_slug}/",
|
||||||
f"https://neko-sama.org/series/{search_slug}/",
|
f"https://neko-sama.org/series/{search_slug}/",
|
||||||
@@ -250,34 +276,40 @@ class NekoSamaDownloader(BaseAnimeSite):
|
|||||||
print(f"[NEKO-SAMA] Found anime at {final_url}")
|
print(f"[NEKO-SAMA] Found anime at {final_url}")
|
||||||
|
|
||||||
# Extract title from page
|
# Extract title from page
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
title_elem = soup.find('h1') or soup.find('title')
|
title_elem = soup.find("h1") or soup.find("title")
|
||||||
title = unescape(title_elem.get_text(strip=True)) if title_elem else query
|
title = (
|
||||||
|
unescape(title_elem.get_text(strip=True))
|
||||||
|
if title_elem
|
||||||
|
else query
|
||||||
|
)
|
||||||
# Clean up title
|
# Clean up title
|
||||||
title = title.split('|')[0].split('-')[0].strip()
|
title = title.split("|")[0].split("-")[0].strip()
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'title': title,
|
"title": title,
|
||||||
'url': final_url,
|
"url": final_url,
|
||||||
'cover_image': None,
|
"cover_image": None,
|
||||||
'type': 'direct',
|
"type": "direct",
|
||||||
'metadata': None
|
"metadata": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to get poster
|
# Try to get poster
|
||||||
poster = soup.find('img', src=re.compile(r'poster'))
|
poster = soup.find("img", src=re.compile(r"poster"))
|
||||||
if poster:
|
if poster:
|
||||||
result['cover_image'] = poster.get('src')
|
result["cover_image"] = poster.get("src")
|
||||||
|
|
||||||
if include_metadata:
|
if include_metadata:
|
||||||
metadata = await self.get_anime_metadata(final_url)
|
metadata = await self.get_anime_metadata(final_url)
|
||||||
result['metadata'] = metadata
|
result["metadata"] = metadata
|
||||||
|
|
||||||
results.append(result)
|
results.append(result)
|
||||||
break
|
break
|
||||||
|
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
print(f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results")
|
print(
|
||||||
|
f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results"
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
|
|
||||||
BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"]
|
BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.id = "vostfree"
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
||||||
|
|
||||||
@@ -16,35 +20,35 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
"""Extract download link from vostfree URL"""
|
"""Extract download link from vostfree URL"""
|
||||||
try:
|
try:
|
||||||
response = await self.client.get(url, follow_redirects=True)
|
response = await self.client.get(url, follow_redirects=True)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
# Method 1: Look for iframe players
|
# Method 1: Look for iframe players
|
||||||
iframes = soup.find_all('iframe')
|
iframes = soup.find_all("iframe")
|
||||||
for iframe in iframes:
|
for iframe in iframes:
|
||||||
src = iframe.get('src', '')
|
src = iframe.get("src", "")
|
||||||
if src and any(p in src for p in ['player', 'video', 'stream']):
|
if src and any(p in src for p in ["player", "video", "stream"]):
|
||||||
if not src.startswith('http'):
|
if not src.startswith("http"):
|
||||||
src = urljoin(str(response.url), src)
|
src = urljoin(str(response.url), src)
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 2: Look for video tags
|
# Method 2: Look for video tags
|
||||||
videos = soup.find_all('video')
|
videos = soup.find_all("video")
|
||||||
for video in videos:
|
for video in videos:
|
||||||
src = video.get('src')
|
src = video.get("src")
|
||||||
if src:
|
if src:
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
sources = video.find_all('source')
|
sources = video.find_all("source")
|
||||||
for source in sources:
|
for source in sources:
|
||||||
src = source.get('src', '')
|
src = source.get("src", "")
|
||||||
if src and any(ext in src for ext in ['mp4', 'm3u8']):
|
if src and any(ext in src for ext in ["mp4", "m3u8"]):
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return src, filename
|
return src, filename
|
||||||
|
|
||||||
# Method 3: Look in scripts
|
# Method 3: Look in scripts
|
||||||
scripts = soup.find_all('script')
|
scripts = soup.find_all("script")
|
||||||
for script in scripts:
|
for script in scripts:
|
||||||
if script.string:
|
if script.string:
|
||||||
patterns = [
|
patterns = [
|
||||||
@@ -56,8 +60,8 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
matches = re.findall(pattern, script.string)
|
matches = re.findall(pattern, script.string)
|
||||||
for match in matches:
|
for match in matches:
|
||||||
match = match.replace('\\/', '/')
|
match = match.replace("\\/", "/")
|
||||||
if any(ext in match for ext in ['mp4', 'm3u8']):
|
if any(ext in match for ext in ["mp4", "m3u8"]):
|
||||||
filename = self._generate_filename(str(response.url))
|
filename = self._generate_filename(str(response.url))
|
||||||
return match, filename
|
return match, filename
|
||||||
|
|
||||||
@@ -67,12 +71,12 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
raise Exception(f"Error extracting Vostfree link: {str(e)}")
|
raise Exception(f"Error extracting Vostfree link: {str(e)}")
|
||||||
|
|
||||||
def _generate_filename(self, url: str) -> str:
|
def _generate_filename(self, url: str) -> str:
|
||||||
parts = url.split('/')
|
parts = url.split("/")
|
||||||
anime_name = "anime"
|
anime_name = "anime"
|
||||||
episode = "1"
|
episode = "1"
|
||||||
|
|
||||||
for part in parts:
|
for part in parts:
|
||||||
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
|
match = re.search(r"episode[-\s]*(\d+)", part, re.I)
|
||||||
if match:
|
if match:
|
||||||
episode = match.group(1)
|
episode = match.group(1)
|
||||||
|
|
||||||
@@ -82,30 +86,30 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
|
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
|
||||||
try:
|
try:
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
episodes = []
|
episodes = []
|
||||||
episode_links = soup.find_all('a', href=re.compile(r'episode', re.I))
|
episode_links = soup.find_all("a", href=re.compile(r"episode", re.I))
|
||||||
|
|
||||||
for link in episode_links:
|
for link in episode_links:
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
|
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
|
||||||
if match:
|
if match:
|
||||||
episode_num = match.group(1)
|
episode_num = match.group(1)
|
||||||
if not href.startswith('http'):
|
if not href.startswith("http"):
|
||||||
href = urljoin(anime_url, href)
|
href = urljoin(anime_url, href)
|
||||||
|
|
||||||
episodes.append({'episode': episode_num, 'url': href})
|
episodes.append({"episode": episode_num, "url": href})
|
||||||
|
|
||||||
# Deduplicate and sort
|
# Deduplicate and sort
|
||||||
seen = set()
|
seen = set()
|
||||||
unique_episodes = []
|
unique_episodes = []
|
||||||
for ep in episodes:
|
for ep in episodes:
|
||||||
if ep['episode'] not in seen:
|
if ep["episode"] not in seen:
|
||||||
seen.add(ep['episode'])
|
seen.add(ep["episode"])
|
||||||
unique_episodes.append(ep)
|
unique_episodes.append(ep)
|
||||||
|
|
||||||
unique_episodes.sort(key=lambda x: int(x['episode']))
|
unique_episodes.sort(key=lambda x: int(x["episode"]))
|
||||||
return unique_episodes
|
return unique_episodes
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -119,29 +123,29 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
try:
|
try:
|
||||||
print(f"[VOSTFREE] Extracting metadata from: {anime_url}")
|
print(f"[VOSTFREE] Extracting metadata from: {anime_url}")
|
||||||
response = await self.client.get(anime_url)
|
response = await self.client.get(anime_url)
|
||||||
soup = BeautifulSoup(response.text, 'lxml')
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
'synopsis': None,
|
"synopsis": None,
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'rating': None,
|
"rating": None,
|
||||||
'release_year': None,
|
"release_year": None,
|
||||||
'studio': None,
|
"studio": None,
|
||||||
'poster_image': None,
|
"poster_image": None,
|
||||||
'banner_image': None,
|
"banner_image": None,
|
||||||
'total_episodes': None,
|
"total_episodes": None,
|
||||||
'status': None,
|
"status": None,
|
||||||
'alternative_titles': []
|
"alternative_titles": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract synopsis
|
# Extract synopsis
|
||||||
synopsis_selectors = [
|
synopsis_selectors = [
|
||||||
'div.synopsis',
|
"div.synopsis",
|
||||||
'div.description',
|
"div.description",
|
||||||
'div[class*="synopsis"]',
|
'div[class*="synopsis"]',
|
||||||
'div[class*="desc"]',
|
'div[class*="desc"]',
|
||||||
'p.synopsis',
|
"p.synopsis",
|
||||||
'.anime-synopsis'
|
".anime-synopsis",
|
||||||
]
|
]
|
||||||
|
|
||||||
for selector in synopsis_selectors:
|
for selector in synopsis_selectors:
|
||||||
@@ -149,57 +153,65 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
if synopsis_elem:
|
if synopsis_elem:
|
||||||
synopsis = synopsis_elem.get_text(strip=True)
|
synopsis = synopsis_elem.get_text(strip=True)
|
||||||
if len(synopsis) > 50:
|
if len(synopsis) > 50:
|
||||||
metadata['synopsis'] = synopsis
|
metadata["synopsis"] = synopsis
|
||||||
break
|
break
|
||||||
|
|
||||||
# Extract genres
|
# Extract genres
|
||||||
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I))
|
genre_links = soup.find_all("a", href=re.compile(r"genre|tag|type", re.I))
|
||||||
if genre_links:
|
if genre_links:
|
||||||
metadata['genres'] = [link.get_text(strip=True) for link in genre_links[:5]]
|
metadata["genres"] = [
|
||||||
|
link.get_text(strip=True) for link in genre_links[:5]
|
||||||
|
]
|
||||||
|
|
||||||
# Extract rating
|
# Extract rating
|
||||||
rating_selectors = [
|
rating_selectors = [
|
||||||
'span.rating',
|
"span.rating",
|
||||||
'div.rating',
|
"div.rating",
|
||||||
'span.score',
|
"span.score",
|
||||||
'div[class*="rating"]',
|
'div[class*="rating"]',
|
||||||
'div[class*="score"]'
|
'div[class*="score"]',
|
||||||
]
|
]
|
||||||
|
|
||||||
for selector in rating_selectors:
|
for selector in rating_selectors:
|
||||||
rating_elem = soup.select_one(selector)
|
rating_elem = soup.select_one(selector)
|
||||||
if rating_elem:
|
if rating_elem:
|
||||||
rating_text = rating_elem.get_text(strip=True)
|
rating_text = rating_elem.get_text(strip=True)
|
||||||
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
|
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
|
||||||
if rating_match:
|
if rating_match:
|
||||||
metadata['rating'] = f"{rating_match.group(1)}/10"
|
metadata["rating"] = f"{rating_match.group(1)}/10"
|
||||||
break
|
break
|
||||||
|
|
||||||
# Extract release year
|
# Extract release year
|
||||||
page_text = soup.get_text()
|
page_text = soup.get_text()
|
||||||
year_matches = re.findall(r'\b(19\d{2}|20\d{2})\b', page_text)
|
year_matches = re.findall(r"\b(19\d{2}|20\d{2})\b", page_text)
|
||||||
if year_matches:
|
if year_matches:
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
current_year = datetime.datetime.now().year + 2
|
current_year = datetime.datetime.now().year + 2
|
||||||
valid_years = [int(y) for y in year_matches if 1950 <= int(y) <= current_year]
|
valid_years = [
|
||||||
|
int(y) for y in year_matches if 1950 <= int(y) <= current_year
|
||||||
|
]
|
||||||
if valid_years:
|
if valid_years:
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
metadata['release_year'] = Counter(valid_years).most_common(1)[0][0]
|
|
||||||
|
metadata["release_year"] = Counter(valid_years).most_common(1)[0][0]
|
||||||
|
|
||||||
# Extract poster image
|
# Extract poster image
|
||||||
poster_elem = soup.select_one('img.poster, img.cover, .anime-poster img')
|
poster_elem = soup.select_one("img.poster, img.cover, .anime-poster img")
|
||||||
if poster_elem:
|
if poster_elem:
|
||||||
metadata['poster_image'] = poster_elem.get('src') or poster_elem.get('data-src')
|
metadata["poster_image"] = poster_elem.get("src") or poster_elem.get(
|
||||||
|
"data-src"
|
||||||
|
)
|
||||||
|
|
||||||
# Extract poster from og:image
|
# Extract poster from og:image
|
||||||
og_image = soup.find('meta', property='og:image')
|
og_image = soup.find("meta", property="og:image")
|
||||||
if og_image and not metadata['poster_image']:
|
if og_image and not metadata["poster_image"]:
|
||||||
metadata['poster_image'] = og_image.get('content')
|
metadata["poster_image"] = og_image.get("content")
|
||||||
|
|
||||||
# Extract total episodes
|
# Extract total episodes
|
||||||
episodes_count = len(await self.get_episodes(anime_url))
|
episodes_count = len(await self.get_episodes(anime_url))
|
||||||
if episodes_count > 0:
|
if episodes_count > 0:
|
||||||
metadata['total_episodes'] = episodes_count
|
metadata["total_episodes"] = episodes_count
|
||||||
|
|
||||||
print(f"[VOSTFREE] Extracted metadata: {metadata}")
|
print(f"[VOSTFREE] Extracted metadata: {metadata}")
|
||||||
return metadata
|
return metadata
|
||||||
@@ -208,7 +220,9 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
print(f"[VOSTFREE] Error extracting metadata: {e}")
|
print(f"[VOSTFREE] Error extracting metadata: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
|
async def search_anime(
|
||||||
|
self, query: str, lang: str = "vostfr", include_metadata: bool = False
|
||||||
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Search for anime on vostfree
|
Search for anime on vostfree
|
||||||
|
|
||||||
@@ -219,6 +233,7 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import time
|
import time
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
print(f"[VOSTFREE] Searching for '{query}' ({lang})...")
|
print(f"[VOSTFREE] Searching for '{query}' ({lang})...")
|
||||||
|
|
||||||
@@ -233,15 +248,15 @@ class VostfreeDownloader(BaseAnimeSite):
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
print(f"[VOSTFREE] Found anime at {str(response.url)}")
|
print(f"[VOSTFREE] Found anime at {str(response.url)}")
|
||||||
result = {
|
result = {
|
||||||
'title': query,
|
"title": query,
|
||||||
'url': str(response.url),
|
"url": str(response.url),
|
||||||
'type': 'direct',
|
"type": "direct",
|
||||||
'metadata': None
|
"metadata": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if include_metadata:
|
if include_metadata:
|
||||||
metadata = await self.get_anime_metadata(str(response.url))
|
metadata = await self.get_anime_metadata(str(response.url))
|
||||||
result['metadata'] = metadata
|
result["metadata"] = metadata
|
||||||
|
|
||||||
return [result]
|
return [result]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from .base import BaseSeriesSite
|
||||||
# Import all series site downloaders
|
# Import all series site downloaders
|
||||||
from .fs7 import FS7Downloader
|
from .fs7 import FS7Downloader
|
||||||
|
from .zonetelechargement import ZoneTelechargementDownloader
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseSeriesSite",
|
"BaseSeriesSite",
|
||||||
"FS7Downloader",
|
"FS7Downloader",
|
||||||
|
"ZoneTelechargementDownloader",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ def get_series_site(url: str) -> BaseSeriesSite:
|
|||||||
"""Factory function to get the appropriate series site for a URL"""
|
"""Factory function to get the appropriate series site for a URL"""
|
||||||
sites = [
|
sites = [
|
||||||
FS7Downloader(),
|
FS7Downloader(),
|
||||||
|
ZoneTelechargementDownloader(),
|
||||||
]
|
]
|
||||||
|
|
||||||
for site in sites:
|
for site in sites:
|
||||||
|
|||||||
+271
-119
@@ -1,4 +1,5 @@
|
|||||||
"""FS7 (French Stream) series site downloader"""
|
"""FS7 (French Stream) series site downloader"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
@@ -19,29 +20,46 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.base_url = "https://fs7.lol"
|
self.id = "fs7"
|
||||||
self.search_url = f"{self.base_url}/"
|
self.provider_id = "fs7"
|
||||||
# Update client headers to mimic browser
|
self.default_domain = "fs7.lol"
|
||||||
self.client.headers.update({
|
self.test_tlds = ["lol", "one", "site", "vip", "fun", "stream", "com", "net", "org", "tv", "ws", "cc", "co"]
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
self.base_url = f"https://{self.default_domain}"
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
self._domain_checked = False
|
||||||
'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7',
|
self.client.headers.update(
|
||||||
'Accept-Encoding': 'gzip, deflate',
|
{
|
||||||
'Connection': 'keep-alive',
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
'Upgrade-Insecure-Requests': '1'
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||||
})
|
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
|
"Accept-Encoding": "gzip, deflate",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _ensure_base_url(self):
|
||||||
|
"""Ensure base_url is set to the current active domain"""
|
||||||
|
if self._domain_checked:
|
||||||
|
return
|
||||||
|
self._domain_checked = True
|
||||||
|
try:
|
||||||
|
from app.utils import DomainManager
|
||||||
|
|
||||||
|
active_domain = await DomainManager.get_active_domain(
|
||||||
|
self.provider_id, self.default_domain, self.test_tlds, test_path="/"
|
||||||
|
)
|
||||||
|
self.base_url = f"https://{active_domain}"
|
||||||
|
logger.info(f"Using active domain for FS7: {self.base_url}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Domain check failed for FS7, using default: {e}")
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
"""Check if this downloader can handle the given URL"""
|
"""Check if this downloader can handle the given URL"""
|
||||||
return "fs7.lol" in url.lower() or "french-stream" in url.lower()
|
return "fs7.lol" in url.lower() or "french-stream" in url.lower()
|
||||||
|
|
||||||
async def search_anime(
|
async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
lang: str = "vf"
|
|
||||||
) -> List[Dict[str, str]]:
|
|
||||||
"""
|
"""
|
||||||
Search for series on FS7.
|
Search for series on FS7 using DLE AJAX search endpoint.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query
|
query: Search query
|
||||||
@@ -51,63 +69,61 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
List of series with title, url, cover_image
|
List of series with title, url, cover_image
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
await self._ensure_base_url()
|
||||||
logger.info(f"Searching FS7 for: {query}")
|
logger.info(f"Searching FS7 for: {query}")
|
||||||
|
|
||||||
# FS7 uses GET request with query parameters for search
|
ajax_url = f"{self.base_url}/engine/ajax/search.php"
|
||||||
response = await self.client.get(
|
response = await self.client.post(
|
||||||
self.search_url,
|
ajax_url,
|
||||||
params={
|
data={"query": query, "page": "1"},
|
||||||
"do": "search",
|
headers={
|
||||||
"subaction": "search",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"story": query
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
}
|
"Referer": f"{self.base_url}/",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Look for series items (FS7 has both films and series in search results)
|
for item in soup.find_all("div", class_="search-item")[:24]:
|
||||||
# We filter for /s-tv/ URLs ending with .html (actual series/season pages)
|
onclick = item.get("onclick", "")
|
||||||
items = soup.find_all('a', href=re.compile(r'/s-tv/\d+-.+\.html'))
|
url_match = re.search(r"location\.href=['\"]([^'\"]+)['\"]", onclick)
|
||||||
|
if not url_match:
|
||||||
for item in items[:20]: # Limit to 20 results
|
continue
|
||||||
url = item.get('href', '')
|
url = url_match.group(1)
|
||||||
if not url.startswith('http'):
|
if not url.startswith("http"):
|
||||||
url = urljoin(self.base_url, url)
|
url = urljoin(self.base_url, url)
|
||||||
|
|
||||||
# Extract title from the item
|
title_elem = item.find("div", class_="search-title")
|
||||||
title_elem = item.find('img', alt=True)
|
title = title_elem.get_text(strip=True) if title_elem else ""
|
||||||
if title_elem:
|
title = re.sub(r"\s+", " ", title).strip()
|
||||||
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
|
|
||||||
|
|
||||||
# Clean up title: remove "affiche" suffix and clean extra whitespace
|
cover_image = ""
|
||||||
title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip()
|
poster_elem = item.find("div", class_="search-poster")
|
||||||
title = re.sub(r'\s+', ' ', title) # Normalize whitespace
|
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
|
if title and len(title) > 2:
|
||||||
img = item.find('img')
|
results.append(
|
||||||
cover_image = img.get('src', '') if img else ''
|
{
|
||||||
|
"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
|
logger.info(f"Found {len(results)} results on FS7 for '{query}'")
|
||||||
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")
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -115,9 +131,7 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_episodes(
|
async def get_episodes(
|
||||||
self,
|
self, anime_url: str, lang: str = "vf"
|
||||||
anime_url: str,
|
|
||||||
lang: str = "vf"
|
|
||||||
) -> List[Dict[str, str]]:
|
) -> List[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Get episode list for a series.
|
Get episode list for a series.
|
||||||
@@ -136,31 +150,33 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
episodes = []
|
episodes = []
|
||||||
|
|
||||||
# Get series title for episode naming
|
# Get series title for episode naming
|
||||||
title_elem = soup.find('h1')
|
title_elem = soup.find("h1")
|
||||||
series_title = title_elem.get_text(strip=True) if title_elem else "Series"
|
series_title = title_elem.get_text(strip=True) if title_elem else "Series"
|
||||||
# Clean up title: remove "affiche" suffix
|
# Clean up title: remove "affiche" suffix
|
||||||
series_title = re.sub(r'\s+affiche$', '', series_title, flags=re.IGNORECASE).strip()
|
series_title = re.sub(
|
||||||
|
r"\s+affiche$", "", series_title, flags=re.IGNORECASE
|
||||||
|
).strip()
|
||||||
|
|
||||||
# FS7 stores episode data in JavaScript div elements
|
# FS7 stores episode data in JavaScript div elements
|
||||||
# Format: <div data-ep="1" data-vidzy="..." data-uqload="..." data-netu="..." data-voe="..."></div>
|
# Format: <div data-ep="1" data-vidzy="..." data-uqload="..." data-netu="..." data-voe="..."></div>
|
||||||
episode_divs = soup.find_all('div', attrs={'data-ep': True})
|
episode_divs = soup.find_all("div", attrs={"data-ep": True})
|
||||||
|
|
||||||
for div in episode_divs:
|
for div in episode_divs:
|
||||||
ep_num = div.get('data-ep', '').strip()
|
ep_num = div.get("data-ep", "").strip()
|
||||||
|
|
||||||
# Try different video players in order of preference
|
# Try different video players in order of preference
|
||||||
video_url = None
|
video_url = None
|
||||||
host_name = None
|
host_name = None
|
||||||
for player in ['data-vidzy', 'data-uqload', 'data-voe', 'data-netu']:
|
for player in ["data-vidzy", "data-uqload", "data-voe", "data-netu"]:
|
||||||
player_url = div.get(player, '').strip()
|
player_url = div.get(player, "").strip()
|
||||||
if player_url:
|
if player_url:
|
||||||
video_url = player_url
|
video_url = player_url
|
||||||
# Extract host name from attribute name
|
# Extract host name from attribute name
|
||||||
host_name = player.replace('data-', '').title()
|
host_name = player.replace("data-", "").title()
|
||||||
logger.debug(f"Found episode {ep_num} on {host_name}")
|
logger.debug(f"Found episode {ep_num} on {host_name}")
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -171,15 +187,19 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
# Use pipe-separated format: video_url|anime_url|episode_title
|
# Use pipe-separated format: video_url|anime_url|episode_title
|
||||||
combined_url = f"{video_url}|{anime_url}|{episode_title}"
|
combined_url = f"{video_url}|{anime_url}|{episode_title}"
|
||||||
|
|
||||||
episodes.append({
|
episodes.append(
|
||||||
'episode': ep_num,
|
{
|
||||||
'url': combined_url,
|
"episode": ep_num,
|
||||||
'title': episode_title,
|
"url": combined_url,
|
||||||
'host': host_name or 'Unknown'
|
"title": episode_title,
|
||||||
})
|
"host": host_name or "Unknown",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Sort by episode number
|
# Sort by episode number
|
||||||
episodes.sort(key=lambda x: int(x['episode']) if x['episode'].isdigit() else 0)
|
episodes.sort(
|
||||||
|
key=lambda x: int(x["episode"]) if x["episode"].isdigit() else 0
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Found {len(episodes)} episodes")
|
logger.info(f"Found {len(episodes)} episodes")
|
||||||
return episodes
|
return episodes
|
||||||
@@ -188,10 +208,7 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
logger.error(f"Error getting episodes from FS7: {e}")
|
logger.error(f"Error getting episodes from FS7: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_anime_metadata(
|
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
|
||||||
self,
|
|
||||||
anime_url: str
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
Get metadata for a series.
|
Get metadata for a series.
|
||||||
|
|
||||||
@@ -208,62 +225,120 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
soup = BeautifulSoup(html, 'lxml')
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
# Extract title
|
# Extract title
|
||||||
title = soup.find('h1')
|
title = soup.find("h1")
|
||||||
title = title.get_text(strip=True) if title else "Unknown"
|
title = title.get_text(strip=True) if title else "Unknown"
|
||||||
|
|
||||||
# Clean up title: remove "affiche" suffix
|
# Clean up title: remove "affiche" suffix
|
||||||
title = re.sub(r'\s+affiche$', '', title, flags=re.IGNORECASE).strip()
|
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
# Extract description/synopsis
|
# --- Synopsis: div.fdesc > p ---
|
||||||
description_elem = soup.find('div', class_='full-text')
|
description = ""
|
||||||
description = description_elem.get_text(strip=True) if description_elem else ""
|
fdesc = soup.find("div", class_="fdesc")
|
||||||
|
if fdesc:
|
||||||
|
p = fdesc.find("p")
|
||||||
|
if p:
|
||||||
|
description = p.get_text(strip=True)
|
||||||
|
else:
|
||||||
|
description = fdesc.get_text(strip=True)
|
||||||
|
|
||||||
# Extract cover image
|
# --- Poster: div.fleft > img ---
|
||||||
img = soup.find('img', class_='poster')
|
poster_image = ""
|
||||||
poster_image = img.get('src', '') if img else ''
|
fleft = soup.find("div", class_="fleft")
|
||||||
|
if fleft:
|
||||||
|
img = fleft.find("img")
|
||||||
|
if img:
|
||||||
|
poster_image = (
|
||||||
|
img.get("data-src")
|
||||||
|
or img.get("data-original")
|
||||||
|
or img.get("src")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
# Try to get poster from meta tag if not found
|
# Fallback: img.poster, then og:image
|
||||||
if not poster_image:
|
if not poster_image:
|
||||||
meta_img = soup.find('meta', property='og:image')
|
img = soup.find("img", class_="poster")
|
||||||
poster_image = meta_img.get('content', '') if meta_img else ''
|
poster_image = img.get("src", "") if img else ""
|
||||||
|
if not poster_image:
|
||||||
|
meta_img = soup.find("meta", property="og:image")
|
||||||
|
poster_image = meta_img.get("content", "") if meta_img else ""
|
||||||
|
|
||||||
# Extract year
|
# --- Year: span.release ---
|
||||||
year_match = re.search(r'\b(19|20)\d{2}\b', description)
|
release_year = None
|
||||||
release_year = int(year_match.group()) if year_match else None
|
release_span = soup.find("span", class_="release")
|
||||||
|
if release_span:
|
||||||
|
year_match = re.search(r"\b(19|20)\d{2}\b", release_span.get_text())
|
||||||
|
if year_match:
|
||||||
|
release_year = int(year_match.group())
|
||||||
|
|
||||||
|
# --- Genres: span.genres ---
|
||||||
|
genres = []
|
||||||
|
genres_span = soup.find("span", class_="genres")
|
||||||
|
if genres_span:
|
||||||
|
genres = [
|
||||||
|
g.strip()
|
||||||
|
for g in genres_span.get_text().split(",")
|
||||||
|
if g.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Runtime: span.runtime ---
|
||||||
|
runtime = None
|
||||||
|
runtime_span = soup.find("span", class_="runtime")
|
||||||
|
if runtime_span:
|
||||||
|
runtime = runtime_span.get_text(strip=True)
|
||||||
|
|
||||||
|
# --- Casting info from second div.flist ---
|
||||||
|
original_title = ""
|
||||||
|
director = ""
|
||||||
|
cast = []
|
||||||
|
flists = soup.find_all("div", class_="flist")
|
||||||
|
for fl in flists:
|
||||||
|
text = fl.get_text(strip=True)
|
||||||
|
if "Titre Original" in text:
|
||||||
|
m = re.search(r"Titre Original\s*:\s*(.+?)(?:Réalisateur|$)", text)
|
||||||
|
if m:
|
||||||
|
original_title = m.group(1).strip()
|
||||||
|
m2 = re.search(r"Réalisateur\s*:\s*(.+?)(?:Avec\s*:|$)", text)
|
||||||
|
if m2:
|
||||||
|
director = m2.group(1).strip()
|
||||||
|
m3 = re.search(r"Avec\s*:\s*(.+?)(?:plus|$)", text)
|
||||||
|
if m3:
|
||||||
|
cast = [c.strip() for c in m3.group(1).split(",") if c.strip()]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'title': title,
|
"title": title,
|
||||||
'synopsis': description,
|
"synopsis": description,
|
||||||
'poster_image': poster_image,
|
"poster_image": poster_image,
|
||||||
'release_year': release_year,
|
"release_year": release_year,
|
||||||
'genres': [],
|
"genres": genres,
|
||||||
'rating': None,
|
"rating": None,
|
||||||
'studio': None,
|
"studio": None,
|
||||||
'total_episodes': None,
|
"total_episodes": None,
|
||||||
'status': None
|
"status": None,
|
||||||
|
"original_title": original_title,
|
||||||
|
"director": director,
|
||||||
|
"cast": cast,
|
||||||
|
"runtime": runtime,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting metadata from FS7: {e}")
|
logger.error(f"Error getting metadata from FS7: {e}")
|
||||||
return {
|
return {
|
||||||
'title': "Unknown",
|
"title": "Unknown",
|
||||||
'synopsis': "",
|
"synopsis": "",
|
||||||
'poster_image': '',
|
"poster_image": "",
|
||||||
'genres': [],
|
"genres": [],
|
||||||
'rating': None,
|
"rating": None,
|
||||||
'release_year': None,
|
"release_year": None,
|
||||||
'studio': None,
|
"studio": None,
|
||||||
'total_episodes': None,
|
"total_episodes": None,
|
||||||
'status': None
|
"status": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_download_link(
|
async def get_download_link(
|
||||||
self,
|
self, url: str, target_filename: Optional[str] = None
|
||||||
url: str,
|
|
||||||
target_filename: Optional[str] = None
|
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Extract download link from video player URL.
|
Extract download link from video player URL.
|
||||||
@@ -284,3 +359,80 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
return await player.get_download_link(url, target_filename)
|
return await player.get_download_link(url, target_filename)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"No video player found for URL: {url}")
|
raise ValueError(f"No video player found for URL: {url}")
|
||||||
|
|
||||||
|
async def get_latest_series(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Scrape the 'Nouveautés Séries' section from FS7 homepage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with title, url, cover_image, synopsis, lang, provider_id.
|
||||||
|
"""
|
||||||
|
await self._ensure_base_url()
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self.client.get(self.base_url + "/", timeout=15)
|
||||||
|
soup = BeautifulSoup(resp.text, "html.parser")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch FS7 homepage: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Find the 'Nouveautés Séries' section
|
||||||
|
for section in soup.find_all("div", class_="pages"):
|
||||||
|
title_el = section.find("div", class_="sect-t")
|
||||||
|
if not title_el:
|
||||||
|
continue
|
||||||
|
title = title_el.get_text(strip=True)
|
||||||
|
if "Nouveautés" not in title or "Séries" not in title:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in section.find_all("div", class_="short"):
|
||||||
|
# Get the poster link (contains real URL)
|
||||||
|
poster_a = item.find("a", class_="short-poster", href=True)
|
||||||
|
if not poster_a:
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = poster_a["href"]
|
||||||
|
if url.startswith("/"):
|
||||||
|
url = self.base_url + url
|
||||||
|
|
||||||
|
# Title from alt attribute
|
||||||
|
title_attr = poster_a.get("alt", "").strip()
|
||||||
|
if not title_attr:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Poster image
|
||||||
|
img = poster_a.find("img")
|
||||||
|
cover_image = img.get("src", "") if img else ""
|
||||||
|
|
||||||
|
# Synopsis from hidden span
|
||||||
|
desc_span = item.find("span", id=re.compile(r"^desc-\d+"))
|
||||||
|
synopsis = desc_span.get_text(strip=True) if desc_span else ""
|
||||||
|
|
||||||
|
# Language (VF/VOSTFR)
|
||||||
|
lang = "vf"
|
||||||
|
version_span = item.find("span", class_="film-version")
|
||||||
|
if version_span:
|
||||||
|
version_text = version_span.get_text(strip=True).upper()
|
||||||
|
if "VOSTFR" in version_text:
|
||||||
|
lang = "vostfr"
|
||||||
|
elif "VF" in version_text:
|
||||||
|
lang = "vf"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"title": title_attr,
|
||||||
|
"url": url,
|
||||||
|
"cover_image": cover_image,
|
||||||
|
"synopsis": synopsis,
|
||||||
|
"lang": lang,
|
||||||
|
"provider_id": self.provider_id,
|
||||||
|
"content_type": "series",
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
break # Only process the first matching section
|
||||||
|
|
||||||
|
logger.info(f"FS7 latest series: found {len(results)} items")
|
||||||
|
return results
|
||||||
|
|||||||
@@ -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 .vidzy import VidzyDownloader
|
||||||
from .luluv import LuLuvidDownloader
|
from .luluv import LuLuvidDownloader
|
||||||
from .uqload import UqloadDownloader
|
from .uqload import UqloadDownloader
|
||||||
|
from .smoothpre import SmoothpreDownloader
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseVideoPlayer",
|
"BaseVideoPlayer",
|
||||||
@@ -26,6 +27,7 @@ __all__ = [
|
|||||||
"VidzyDownloader",
|
"VidzyDownloader",
|
||||||
"LuLuvidDownloader",
|
"LuLuvidDownloader",
|
||||||
"UqloadDownloader",
|
"UqloadDownloader",
|
||||||
|
"SmoothpreDownloader",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ def get_video_player(url: str) -> BaseVideoPlayer:
|
|||||||
VidzyDownloader(),
|
VidzyDownloader(),
|
||||||
LuLuvidDownloader(),
|
LuLuvidDownloader(),
|
||||||
UqloadDownloader(),
|
UqloadDownloader(),
|
||||||
|
SmoothpreDownloader(),
|
||||||
]
|
]
|
||||||
|
|
||||||
for player in players:
|
for player in players:
|
||||||
|
|||||||
+96
-86
@@ -1,52 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
Favorites management system for Ohm Stream Downloader
|
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
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from datetime import datetime
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FavoritesManager:
|
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"):
|
def __init__(self, storage_path: str = None):
|
||||||
self.storage_path = Path(storage_path)
|
# Database connection is managed via engine and sessions
|
||||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
pass
|
||||||
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}")
|
|
||||||
|
|
||||||
async def add_favorite(
|
async def add_favorite(
|
||||||
self,
|
self,
|
||||||
@@ -55,67 +27,88 @@ class FavoritesManager:
|
|||||||
url: str,
|
url: str,
|
||||||
provider: str,
|
provider: str,
|
||||||
metadata: Optional[Dict] = None,
|
metadata: Optional[Dict] = None,
|
||||||
poster_url: Optional[str] = None
|
poster_url: Optional[str] = None,
|
||||||
|
user_id: str = "default"
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Add an anime to favorites"""
|
"""Add an anime to favorites"""
|
||||||
async with self._lock:
|
with Session(engine) as session:
|
||||||
await self._load_for_operation()
|
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
|
# Update existing favorite
|
||||||
self._favorites[anime_id]["updated_at"] = datetime.now().isoformat()
|
existing.updated_at = datetime.now()
|
||||||
if metadata:
|
if metadata:
|
||||||
self._favorites[anime_id]["metadata"] = metadata
|
existing.anime_metadata = metadata
|
||||||
if poster_url:
|
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:
|
else:
|
||||||
# Add new favorite
|
# Add new favorite
|
||||||
self._favorites[anime_id] = {
|
fav = FavoriteTable(
|
||||||
"id": anime_id,
|
anime_id=anime_id,
|
||||||
"title": title,
|
title=title,
|
||||||
"url": url,
|
url=url,
|
||||||
"provider": provider,
|
provider=provider,
|
||||||
"metadata": metadata or {},
|
anime_metadata=metadata or {},
|
||||||
"poster_url": poster_url,
|
poster_url=poster_url,
|
||||||
"created_at": datetime.now().isoformat(),
|
user_id=user_id
|
||||||
"updated_at": datetime.now().isoformat()
|
)
|
||||||
}
|
session.add(fav)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(fav)
|
||||||
|
return self._to_dict(fav)
|
||||||
|
|
||||||
await self._save()
|
async def remove_favorite(self, anime_id: str, user_id: str = "default") -> bool:
|
||||||
return self._favorites[anime_id]
|
|
||||||
|
|
||||||
async def remove_favorite(self, anime_id: str) -> bool:
|
|
||||||
"""Remove an anime from favorites"""
|
"""Remove an anime from favorites"""
|
||||||
async with self._lock:
|
with Session(engine) as session:
|
||||||
await self._load_for_operation()
|
statement = select(FavoriteTable).where(
|
||||||
|
FavoriteTable.anime_id == anime_id,
|
||||||
if anime_id in self._favorites:
|
FavoriteTable.user_id == user_id
|
||||||
del self._favorites[anime_id]
|
)
|
||||||
await self._save()
|
existing = session.exec(statement).first()
|
||||||
|
if existing:
|
||||||
|
session.delete(existing)
|
||||||
|
session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
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"""
|
"""Get a specific favorite by ID"""
|
||||||
await self._load()
|
with Session(engine) as session:
|
||||||
return self._favorites.get(anime_id)
|
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(
|
async def list_favorites(
|
||||||
self,
|
self,
|
||||||
|
user_id: str = "default",
|
||||||
sort_by: str = "created_at",
|
sort_by: str = "created_at",
|
||||||
order: str = "desc",
|
order: str = "desc",
|
||||||
filter_provider: Optional[str] = None,
|
filter_provider: Optional[str] = None,
|
||||||
filter_genre: Optional[str] = None
|
filter_genre: Optional[str] = None
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""List all favorites with optional sorting and filtering"""
|
"""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())
|
|
||||||
|
|
||||||
# Apply filters
|
|
||||||
if filter_provider:
|
if filter_provider:
|
||||||
favorites = [f for f in favorites if f["provider"] == filter_provider]
|
statement = statement.where(FavoriteTable.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:
|
if filter_genre:
|
||||||
favorites = [
|
favorites = [
|
||||||
@@ -142,10 +135,14 @@ class FavoritesManager:
|
|||||||
|
|
||||||
return favorites
|
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"""
|
"""Check if an anime is in favorites"""
|
||||||
await self._load()
|
with Session(engine) as session:
|
||||||
return anime_id in self._favorites
|
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(
|
async def toggle_favorite(
|
||||||
self,
|
self,
|
||||||
@@ -154,33 +151,33 @@ class FavoritesManager:
|
|||||||
url: str,
|
url: str,
|
||||||
provider: str,
|
provider: str,
|
||||||
metadata: Optional[Dict] = None,
|
metadata: Optional[Dict] = None,
|
||||||
poster_url: Optional[str] = None
|
poster_url: Optional[str] = None,
|
||||||
|
user_id: str = "default"
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Toggle an anime in favorites (add if not exists, remove if exists)"""
|
"""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:
|
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}
|
return {"action": "removed", "anime_id": anime_id}
|
||||||
else:
|
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}
|
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"""
|
"""Get statistics about favorites"""
|
||||||
await self._load()
|
favorites = await self.list_favorites(user_id=user_id)
|
||||||
|
total = len(favorites)
|
||||||
total = len(self._favorites)
|
|
||||||
|
|
||||||
# Count by provider
|
# Count by provider
|
||||||
by_provider = {}
|
by_provider = {}
|
||||||
for fav in self._favorites.values():
|
for fav in favorites:
|
||||||
provider = fav["provider"]
|
provider = fav["provider"]
|
||||||
by_provider[provider] = by_provider.get(provider, 0) + 1
|
by_provider[provider] = by_provider.get(provider, 0) + 1
|
||||||
|
|
||||||
# Count by genre
|
# Count by genre
|
||||||
by_genre = {}
|
by_genre = {}
|
||||||
for fav in self._favorites.values():
|
for fav in favorites:
|
||||||
for genre in fav.get("metadata", {}).get("genres", []):
|
for genre in fav.get("metadata", {}).get("genres", []):
|
||||||
by_genre[genre] = by_genre.get(genre, 0) + 1
|
by_genre[genre] = by_genre.get(genre, 0) + 1
|
||||||
|
|
||||||
@@ -190,6 +187,19 @@ class FavoritesManager:
|
|||||||
"by_genre": by_genre
|
"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
|
# Global favorites manager instance
|
||||||
_favorites_manager: Optional[FavoritesManager] = None
|
_favorites_manager: Optional[FavoritesManager] = None
|
||||||
|
|||||||
+111
-78
@@ -7,6 +7,7 @@ This module provides intelligent metadata enrichment by:
|
|||||||
3. Normalizing data formats across providers
|
3. Normalizing data formats across providers
|
||||||
4. Caching enriched metadata to reduce API calls
|
4. Caching enriched metadata to reduce API calls
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Optional, List, Set
|
from typing import Dict, Optional, List, Set
|
||||||
@@ -15,6 +16,7 @@ from pathlib import Path
|
|||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
|
import httpx
|
||||||
from app.kitsu_api import KitsuAPI
|
from app.kitsu_api import KitsuAPI
|
||||||
from app.models import AnimeMetadata
|
from app.models import AnimeMetadata
|
||||||
|
|
||||||
@@ -30,9 +32,15 @@ class MetadataEnricher:
|
|||||||
# Fields that Kitsu can provide as fallback
|
# Fields that Kitsu can provide as fallback
|
||||||
# Note: studio is not included as Kitsu API requires separate calls
|
# Note: studio is not included as Kitsu API requires separate calls
|
||||||
KITSU_FIELDS = {
|
KITSU_FIELDS = {
|
||||||
'synopsis', 'genres', 'rating', 'release_year',
|
"synopsis",
|
||||||
'poster_image', 'banner_image', 'total_episodes', 'status',
|
"genres",
|
||||||
'alternative_titles'
|
"rating",
|
||||||
|
"release_year",
|
||||||
|
"poster_image",
|
||||||
|
"banner_image",
|
||||||
|
"total_episodes",
|
||||||
|
"status",
|
||||||
|
"alternative_titles",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache duration in hours
|
# Cache duration in hours
|
||||||
@@ -52,14 +60,15 @@ class MetadataEnricher:
|
|||||||
"""Load metadata cache from disk."""
|
"""Load metadata cache from disk."""
|
||||||
try:
|
try:
|
||||||
if self.cache_file.exists():
|
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)
|
data = json.load(f)
|
||||||
# Filter out expired entries
|
# Filter out expired entries
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
self._cache = {
|
self._cache = {
|
||||||
k: v for k, v in data.items()
|
k: v
|
||||||
if datetime.fromisoformat(v.get('cached_at', '')) >
|
for k, v in data.items()
|
||||||
now - timedelta(hours=self.CACHE_DURATION_HOURS)
|
if datetime.fromisoformat(v.get("cached_at", ""))
|
||||||
|
> now - timedelta(hours=self.CACHE_DURATION_HOURS)
|
||||||
}
|
}
|
||||||
logger.info(f"Loaded {len(self._cache)} cached metadata entries")
|
logger.info(f"Loaded {len(self._cache)} cached metadata entries")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -73,7 +82,7 @@ class MetadataEnricher:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
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)
|
json.dump(self._cache, f, ensure_ascii=False, indent=2)
|
||||||
self._cache_dirty = False
|
self._cache_dirty = False
|
||||||
logger.debug("Saved metadata cache")
|
logger.debug("Saved metadata cache")
|
||||||
@@ -90,10 +99,10 @@ class MetadataEnricher:
|
|||||||
"""Get cached metadata if available and not expired."""
|
"""Get cached metadata if available and not expired."""
|
||||||
if cache_key in self._cache:
|
if cache_key in self._cache:
|
||||||
entry = self._cache[cache_key]
|
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):
|
if cached_at > datetime.now() - timedelta(hours=self.CACHE_DURATION_HOURS):
|
||||||
logger.debug(f"Cache hit for key: {cache_key}")
|
logger.debug(f"Cache hit for key: {cache_key}")
|
||||||
return entry.get('metadata')
|
return entry.get("metadata")
|
||||||
else:
|
else:
|
||||||
# Remove expired entry
|
# Remove expired entry
|
||||||
del self._cache[cache_key]
|
del self._cache[cache_key]
|
||||||
@@ -103,8 +112,8 @@ class MetadataEnricher:
|
|||||||
def _set_cached_metadata(self, cache_key: str, metadata: Dict):
|
def _set_cached_metadata(self, cache_key: str, metadata: Dict):
|
||||||
"""Cache enriched metadata."""
|
"""Cache enriched metadata."""
|
||||||
self._cache[cache_key] = {
|
self._cache[cache_key] = {
|
||||||
'metadata': metadata,
|
"metadata": metadata,
|
||||||
'cached_at': datetime.now().isoformat()
|
"cached_at": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
self._cache_dirty = True
|
self._cache_dirty = True
|
||||||
|
|
||||||
@@ -113,7 +122,7 @@ class MetadataEnricher:
|
|||||||
provider_metadata: Dict,
|
provider_metadata: Dict,
|
||||||
title: str,
|
title: str,
|
||||||
url: Optional[str] = None,
|
url: Optional[str] = None,
|
||||||
use_kitsu_fallback: bool = True
|
use_kitsu_fallback: bool = True,
|
||||||
) -> AnimeMetadata:
|
) -> AnimeMetadata:
|
||||||
"""
|
"""
|
||||||
Enrich provider metadata with Kitsu API fallback.
|
Enrich provider metadata with Kitsu API fallback.
|
||||||
@@ -140,7 +149,9 @@ class MetadataEnricher:
|
|||||||
missing_fields = self._get_missing_fields(enriched)
|
missing_fields = self._get_missing_fields(enriched)
|
||||||
|
|
||||||
if missing_fields and use_kitsu_fallback:
|
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:
|
try:
|
||||||
# Fetch from Kitsu
|
# Fetch from Kitsu
|
||||||
kitsu_metadata = await self._fetch_from_kitsu(title)
|
kitsu_metadata = await self._fetch_from_kitsu(title)
|
||||||
@@ -148,19 +159,27 @@ class MetadataEnricher:
|
|||||||
if kitsu_metadata:
|
if kitsu_metadata:
|
||||||
# Merge Kitsu data
|
# Merge Kitsu data
|
||||||
enriched = self._merge_metadata(enriched, kitsu_metadata)
|
enriched = self._merge_metadata(enriched, kitsu_metadata)
|
||||||
enriched['_kitsu_enriched'] = True
|
enriched["_kitsu_enriched"] = True
|
||||||
enriched['_enriched_fields'] = list(missing_fields)
|
enriched["_enriched_fields"] = list(missing_fields)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to fetch Kitsu metadata for '{title}': {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
|
# Calculate quality score
|
||||||
enriched['_quality_score'] = self._calculate_quality_score(enriched)
|
enriched["_quality_score"] = self._calculate_quality_score(enriched)
|
||||||
|
|
||||||
# Convert to AnimeMetadata
|
# Convert to AnimeMetadata
|
||||||
result = AnimeMetadata(**{
|
result = AnimeMetadata(
|
||||||
k: v for k, v in enriched.items()
|
**{
|
||||||
if not k.startswith('_') # Exclude internal fields
|
k: v
|
||||||
})
|
for k, v in enriched.items()
|
||||||
|
if not k.startswith("_") # Exclude internal fields
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result
|
||||||
self._set_cached_metadata(cache_key, result.model_dump())
|
self._set_cached_metadata(cache_key, result.model_dump())
|
||||||
@@ -176,7 +195,7 @@ class MetadataEnricher:
|
|||||||
missing = set()
|
missing = set()
|
||||||
for field in self.KITSU_FIELDS:
|
for field in self.KITSU_FIELDS:
|
||||||
value = metadata.get(field)
|
value = metadata.get(field)
|
||||||
if value is None or value == [] or value == '':
|
if value is None or value == [] or value == "":
|
||||||
missing.add(field)
|
missing.add(field)
|
||||||
return missing
|
return missing
|
||||||
|
|
||||||
@@ -202,68 +221,85 @@ class MetadataEnricher:
|
|||||||
metadata = {}
|
metadata = {}
|
||||||
|
|
||||||
# Synopsis
|
# Synopsis
|
||||||
if kitsu_data.get('synopsis'):
|
if kitsu_data.get("synopsis"):
|
||||||
metadata['synopsis'] = kitsu_data['synopsis']
|
metadata["synopsis"] = kitsu_data["synopsis"]
|
||||||
|
|
||||||
# Genres
|
# Genres
|
||||||
if kitsu_data.get('genres'):
|
if kitsu_data.get("genres"):
|
||||||
metadata['genres'] = kitsu_data['genres']
|
metadata["genres"] = kitsu_data["genres"]
|
||||||
|
|
||||||
# Rating (Kitsu returns score out of 10, convert to string)
|
# Rating (Kitsu returns score out of 10, convert to string)
|
||||||
if kitsu_data.get('score'):
|
if kitsu_data.get("score"):
|
||||||
score = kitsu_data['score']
|
score = kitsu_data["score"]
|
||||||
if score > 0:
|
if score > 0:
|
||||||
metadata['rating'] = f"{score:.1f}/10"
|
metadata["rating"] = f"{score:.1f}/10"
|
||||||
|
|
||||||
# Release year
|
# Release year
|
||||||
if kitsu_data.get('year'):
|
if kitsu_data.get("year"):
|
||||||
metadata['release_year'] = kitsu_data['year']
|
metadata["release_year"] = kitsu_data["year"]
|
||||||
|
|
||||||
# Poster image
|
# Poster image
|
||||||
if kitsu_data.get('images', {}).get('jpg', {}).get('large_image_url'):
|
if kitsu_data.get("images", {}).get("jpg", {}).get("large_image_url"):
|
||||||
metadata['poster_image'] = kitsu_data['images']['jpg']['large_image_url']
|
metadata["poster_image"] = kitsu_data["images"]["jpg"]["large_image_url"]
|
||||||
elif kitsu_data.get('images', {}).get('jpg', {}).get('image_url'):
|
elif kitsu_data.get("images", {}).get("jpg", {}).get("image_url"):
|
||||||
metadata['poster_image'] = kitsu_data['images']['jpg']['image_url']
|
metadata["poster_image"] = kitsu_data["images"]["jpg"]["image_url"]
|
||||||
|
|
||||||
# Banner image (Kitsu calls it coverImage)
|
# Banner image (Kitsu calls it coverImage)
|
||||||
# Note: Kitsu API structure doesn't clearly separate poster vs banner,
|
# Note: Kitsu API structure doesn't clearly separate poster vs banner,
|
||||||
# but we can use different sizes if available
|
# but we can use different sizes if available
|
||||||
if kitsu_data.get('images', {}).get('webp', {}).get('large_image_url'):
|
if kitsu_data.get("images", {}).get("webp", {}).get("large_image_url"):
|
||||||
metadata['banner_image'] = kitsu_data['images']['webp']['large_image_url']
|
metadata["banner_image"] = kitsu_data["images"]["webp"]["large_image_url"]
|
||||||
|
|
||||||
# Total episodes
|
# Total episodes
|
||||||
if kitsu_data.get('episodes'):
|
if kitsu_data.get("episodes"):
|
||||||
metadata['total_episodes'] = kitsu_data['episodes']
|
metadata["total_episodes"] = kitsu_data["episodes"]
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
if kitsu_data.get('status'):
|
if kitsu_data.get("status"):
|
||||||
# Translate Kitsu status to our format
|
# Translate Kitsu status to our format
|
||||||
status_map = {
|
status_map = {
|
||||||
'Airing': 'Ongoing',
|
"Airing": "Ongoing",
|
||||||
'Finished Airing': 'Completed',
|
"Finished Airing": "Completed",
|
||||||
'To Be Aired': 'Upcoming'
|
"To Be Aired": "Upcoming",
|
||||||
}
|
}
|
||||||
metadata['status'] = status_map.get(
|
metadata["status"] = status_map.get(
|
||||||
kitsu_data['status'],
|
kitsu_data["status"], kitsu_data["status"]
|
||||||
kitsu_data['status']
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Alternative titles
|
# Alternative titles
|
||||||
alt_titles = []
|
alt_titles = []
|
||||||
if kitsu_data.get('title_japanese'):
|
if kitsu_data.get("title_japanese"):
|
||||||
alt_titles.append(kitsu_data['title_japanese'])
|
alt_titles.append(kitsu_data["title_japanese"])
|
||||||
if kitsu_data.get('title_english'):
|
if kitsu_data.get("title_english"):
|
||||||
alt_titles.append(kitsu_data['title_english'])
|
alt_titles.append(kitsu_data["title_english"])
|
||||||
if alt_titles:
|
if alt_titles:
|
||||||
metadata['alternative_titles'] = alt_titles
|
metadata["alternative_titles"] = alt_titles
|
||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
def _merge_metadata(
|
async def _translate_to_french(self, text: str) -> str:
|
||||||
self,
|
"""Translate text to French using Google Translate (free, no key)."""
|
||||||
provider_metadata: Dict,
|
try:
|
||||||
kitsu_metadata: Dict
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
) -> Dict:
|
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.
|
Merge provider and Kitsu metadata, preferring provider data.
|
||||||
|
|
||||||
@@ -285,16 +321,16 @@ class MetadataEnricher:
|
|||||||
Based on completeness of critical fields.
|
Based on completeness of critical fields.
|
||||||
"""
|
"""
|
||||||
weights = {
|
weights = {
|
||||||
'synopsis': 0.2,
|
"synopsis": 0.2,
|
||||||
'genres': 0.15,
|
"genres": 0.15,
|
||||||
'rating': 0.1,
|
"rating": 0.1,
|
||||||
'release_year': 0.1,
|
"release_year": 0.1,
|
||||||
'studio': 0.1,
|
"studio": 0.1,
|
||||||
'poster_image': 0.15,
|
"poster_image": 0.15,
|
||||||
'banner_image': 0.05,
|
"banner_image": 0.05,
|
||||||
'total_episodes': 0.05,
|
"total_episodes": 0.05,
|
||||||
'status': 0.05,
|
"status": 0.05,
|
||||||
'alternative_titles': 0.05
|
"alternative_titles": 0.05,
|
||||||
}
|
}
|
||||||
|
|
||||||
total_weight = sum(weights.values())
|
total_weight = sum(weights.values())
|
||||||
@@ -318,9 +354,7 @@ class MetadataEnricher:
|
|||||||
return round(score / total_weight, 2) if total_weight > 0 else 0.0
|
return round(score / total_weight, 2) if total_weight > 0 else 0.0
|
||||||
|
|
||||||
async def enrich_search_results(
|
async def enrich_search_results(
|
||||||
self,
|
self, results: List[Dict], use_kitsu_fallback: bool = True
|
||||||
results: List[Dict],
|
|
||||||
use_kitsu_fallback: bool = True
|
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Enrich metadata for a list of search results.
|
Enrich metadata for a list of search results.
|
||||||
@@ -338,22 +372,21 @@ class MetadataEnricher:
|
|||||||
enrichment_tasks = []
|
enrichment_tasks = []
|
||||||
for result in results:
|
for result in results:
|
||||||
# Skip if no metadata - will add later in order
|
# Skip if no metadata - will add later in order
|
||||||
if 'metadata' not in result:
|
if "metadata" not in result:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
task = self.enrich_metadata(
|
task = self.enrich_metadata(
|
||||||
provider_metadata=result['metadata'],
|
provider_metadata=result["metadata"],
|
||||||
title=result.get('title', ''),
|
title=result.get("title", ""),
|
||||||
url=result.get('url'),
|
url=result.get("url"),
|
||||||
use_kitsu_fallback=use_kitsu_fallback
|
use_kitsu_fallback=use_kitsu_fallback,
|
||||||
)
|
)
|
||||||
enrichment_tasks.append(task)
|
enrichment_tasks.append(task)
|
||||||
|
|
||||||
# Wait for all enrichment tasks
|
# Wait for all enrichment tasks
|
||||||
if enrichment_tasks:
|
if enrichment_tasks:
|
||||||
enriched_metadata_list = await asyncio.gather(
|
enriched_metadata_list = await asyncio.gather(
|
||||||
*enrichment_tasks,
|
*enrichment_tasks, return_exceptions=True
|
||||||
return_exceptions=True
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update results with enriched metadata
|
# Update results with enriched metadata
|
||||||
@@ -361,7 +394,7 @@ class MetadataEnricher:
|
|||||||
temp_results = {}
|
temp_results = {}
|
||||||
metadata_idx = 0
|
metadata_idx = 0
|
||||||
for i, result in enumerate(results):
|
for i, result in enumerate(results):
|
||||||
if 'metadata' in result:
|
if "metadata" in result:
|
||||||
enriched_meta = enriched_metadata_list[metadata_idx]
|
enriched_meta = enriched_metadata_list[metadata_idx]
|
||||||
|
|
||||||
if isinstance(enriched_meta, Exception):
|
if isinstance(enriched_meta, Exception):
|
||||||
@@ -372,7 +405,7 @@ class MetadataEnricher:
|
|||||||
result_copy = result.copy()
|
result_copy = result.copy()
|
||||||
else:
|
else:
|
||||||
result_copy = result.copy()
|
result_copy = result.copy()
|
||||||
result_copy['metadata'] = enriched_meta.model_dump()
|
result_copy["metadata"] = enriched_meta.model_dump()
|
||||||
|
|
||||||
temp_results[i] = result_copy
|
temp_results[i] = result_copy
|
||||||
metadata_idx += 1
|
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,11 @@ class AnimeSearchResult(BaseModel):
|
|||||||
cover_image: Optional[str] = None
|
cover_image: Optional[str] = None
|
||||||
type: str # "search_result" or "direct"
|
type: str # "search_result" or "direct"
|
||||||
metadata: Optional[AnimeMetadata] = None
|
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
|
||||||
|
from .download import DownloadTaskTable
|
||||||
|
|||||||
+40
-14
@@ -1,15 +1,42 @@
|
|||||||
"""Authentication models for user management"""
|
"""Authentication models for user management with SQLModel support"""
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
import uuid
|
||||||
from typing import Optional
|
from pydantic import BaseModel, EmailStr, Field as PydanticField
|
||||||
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from sqlmodel import SQLModel, Field, Relationship
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserBase(SQLModel):
|
||||||
"""Schema for user registration"""
|
"""Base schema for user data"""
|
||||||
username: str = Field(..., min_length=3, max_length=50)
|
username: str = Field(index=True, unique=True, min_length=3, max_length=50)
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[str] = Field(default=None, index=True)
|
||||||
password: str = Field(..., min_length=6)
|
|
||||||
full_name: Optional[str] = None
|
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):
|
class UserLogin(BaseModel):
|
||||||
@@ -18,13 +45,9 @@ class UserLogin(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(UserBase):
|
||||||
"""Schema for user data"""
|
"""Schema for user data (API Response)"""
|
||||||
id: str
|
id: str
|
||||||
username: str
|
|
||||||
email: Optional[str] = None
|
|
||||||
full_name: Optional[str] = None
|
|
||||||
is_active: bool = True
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
last_login: Optional[datetime] = None
|
last_login: Optional[datetime] = None
|
||||||
|
|
||||||
@@ -38,3 +61,6 @@ class Token(BaseModel):
|
|||||||
class UserInDB(User):
|
class UserInDB(User):
|
||||||
"""Schema for user stored in database (with hashed password)"""
|
"""Schema for user stored in database (with hashed password)"""
|
||||||
hashed_password: str
|
hashed_password: str
|
||||||
|
|
||||||
|
# Import WatchlistItemTable here to resolve SQLModel Relationship mappings
|
||||||
|
from .watchlist import WatchlistItemTable
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Models for download task persistence with SQLModel support"""
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlmodel import SQLModel, Field, Column, String
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
DOWNLOADING = "downloading"
|
||||||
|
PAUSED = "paused"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadTaskTable(SQLModel, table=True):
|
||||||
|
"""Database table for persisting download tasks across server restarts."""
|
||||||
|
__tablename__ = "download_tasks"
|
||||||
|
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
primary_key=True,
|
||||||
|
index=True,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
url: str = Field(default="", sa_column=Column(String))
|
||||||
|
filename: str = Field(sa_column=Column(String))
|
||||||
|
host: str = Field(default="other", sa_column=Column(String))
|
||||||
|
status: str = Field(default="pending", sa_column=Column(String))
|
||||||
|
progress: float = Field(default=0.0)
|
||||||
|
downloaded_bytes: int = Field(default=0)
|
||||||
|
total_bytes: Optional[int] = Field(default=None)
|
||||||
|
speed: float = Field(default=0.0)
|
||||||
|
error: Optional[str] = Field(default=None, sa_column=Column(String))
|
||||||
|
created_at: datetime = Field(default_factory=datetime.now)
|
||||||
|
started_at: Optional[datetime] = Field(default=None)
|
||||||
|
completed_at: Optional[datetime] = Field(default=None)
|
||||||
|
file_path: Optional[str] = Field(default=None, sa_column=Column(String))
|
||||||
@@ -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,94 @@
|
|||||||
|
"""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")
|
||||||
|
|
||||||
|
# #13: Content weight mode ("auto" = based on download habits, "manual" = user-defined)
|
||||||
|
content_weight_mode: str = Field(default="auto", sa_column=Column(String))
|
||||||
|
|
||||||
|
# #14: Manual content weights (used when content_weight_mode = "manual")
|
||||||
|
content_weight_anime: int = Field(default=2)
|
||||||
|
content_weight_series: int = Field(default=1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def disabled_providers(self) -> List[str]:
|
||||||
|
try:
|
||||||
|
return json.loads(self.disabled_providers_json or "[]")
|
||||||
|
except:
|
||||||
|
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"
|
||||||
|
content_weight_mode: str = "auto"
|
||||||
|
content_weight_anime: int = 2
|
||||||
|
content_weight_series: int = 1
|
||||||
|
|
||||||
|
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
|
||||||
|
content_weight_mode: Optional[str] = None
|
||||||
|
content_weight_anime: Optional[int] = None
|
||||||
|
content_weight_series: Optional[int] = None
|
||||||
+55
-6
@@ -1,8 +1,10 @@
|
|||||||
"""Pydantic models for Sonarr webhook integration"""
|
"""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 typing import Optional, Dict, Any, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from sqlmodel import SQLModel, Field
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class SonarrEventType(str, Enum):
|
class SonarrEventType(str, Enum):
|
||||||
@@ -45,7 +47,7 @@ class SonarrEpisodeFile(BaseModel):
|
|||||||
|
|
||||||
class SonarrSeries(BaseModel):
|
class SonarrSeries(BaseModel):
|
||||||
"""Series information from Sonarr"""
|
"""Series information from Sonarr"""
|
||||||
tvdbId: int = Field(..., alias="tvdbId")
|
tvdbId: int = PydanticField(..., alias="tvdbId")
|
||||||
title: str
|
title: str
|
||||||
sortTitle: str
|
sortTitle: str
|
||||||
status: str
|
status: str
|
||||||
@@ -129,8 +131,33 @@ class SonarrWebhookPayload(BaseModel):
|
|||||||
return v
|
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):
|
class SonarrMapping(BaseModel):
|
||||||
"""Mapping between Sonarr series and anime providers"""
|
"""Mapping between Sonarr series and anime providers (API model)"""
|
||||||
sonarr_series_id: int
|
sonarr_series_id: int
|
||||||
sonarr_title: str
|
sonarr_title: str
|
||||||
anime_provider: str # 'anime-sama', 'neko-sama', etc.
|
anime_provider: str # 'anime-sama', 'neko-sama', etc.
|
||||||
@@ -139,8 +166,8 @@ class SonarrMapping(BaseModel):
|
|||||||
lang: str = "vostfr"
|
lang: str = "vostfr"
|
||||||
quality_preference: Optional[str] = None # '1080p', '720p', etc.
|
quality_preference: Optional[str] = None # '1080p', '720p', etc.
|
||||||
auto_download: bool = True
|
auto_download: bool = True
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
created_at: datetime = PydanticField(default_factory=datetime.now)
|
||||||
updated_at: datetime = Field(default_factory=datetime.now)
|
updated_at: datetime = PydanticField(default_factory=datetime.now)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
json_encoders = {
|
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):
|
class SonarrConfig(BaseModel):
|
||||||
"""Sonarr webhook configuration"""
|
"""Sonarr webhook configuration (API Model)"""
|
||||||
webhook_enabled: bool = False
|
webhook_enabled: bool = False
|
||||||
webhook_secret: Optional[str] = None # HMAC SHA256 secret
|
webhook_secret: Optional[str] = None # HMAC SHA256 secret
|
||||||
auto_download_enabled: bool = True
|
auto_download_enabled: bool = True
|
||||||
|
|||||||
+101
-44
@@ -1,8 +1,11 @@
|
|||||||
"""Pydantic models for Watchlist and Auto-Download system"""
|
"""Models for Watchlist and Auto-Download system with SQLModel support"""
|
||||||
from pydantic import BaseModel, Field
|
import uuid
|
||||||
from typing import Optional, Literal
|
import json
|
||||||
|
from pydantic import BaseModel, Field as PydanticField
|
||||||
|
from typing import Optional, Literal, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from sqlmodel import SQLModel, Field, Relationship, Column, String
|
||||||
|
|
||||||
|
|
||||||
class WatchlistStatus(str, Enum):
|
class WatchlistStatus(str, Enum):
|
||||||
@@ -21,34 +24,80 @@ class QualityPreference(str, Enum):
|
|||||||
P480 = "480p" # SD
|
P480 = "480p" # SD
|
||||||
|
|
||||||
|
|
||||||
class WatchlistItem(BaseModel):
|
class WatchlistItemBase(SQLModel):
|
||||||
"""An anime being tracked for automatic episode downloads"""
|
"""Base schema for watchlist items"""
|
||||||
id: str = Field(..., description="Unique identifier (UUID)")
|
anime_title: str = Field(index=True)
|
||||||
user_id: str = Field(..., description="User ID who owns this watchlist item")
|
anime_url: str
|
||||||
anime_title: str = Field(..., description="Title of the anime")
|
provider_id: str
|
||||||
anime_url: str = Field(..., description="URL to the anime page")
|
lang: str = Field(default="vostfr")
|
||||||
provider_id: str = Field(..., description="Provider ID (animesama, nekosama, etc.)")
|
|
||||||
lang: Literal["vostfr", "vf"] = Field(default="vostfr", description="Language preference")
|
|
||||||
|
|
||||||
# Tracking state
|
# Tracking state
|
||||||
last_checked: Optional[datetime] = Field(None, description="Last time we checked for new episodes")
|
last_checked: Optional[datetime] = None
|
||||||
last_episode_downloaded: int = Field(default=0, description="Last episode number downloaded")
|
last_episode_downloaded: int = Field(default=0)
|
||||||
total_episodes: Optional[int] = Field(None, description="Total episodes if known")
|
total_episodes: Optional[int] = None
|
||||||
|
|
||||||
# Settings
|
# Settings
|
||||||
auto_download: bool = Field(default=True, description="Automatically download new episodes")
|
auto_download: bool = Field(default=True)
|
||||||
quality_preference: QualityPreference = Field(default=QualityPreference.AUTO, description="Preferred quality")
|
quality_preference: QualityPreference = Field(default=QualityPreference.AUTO)
|
||||||
status: WatchlistStatus = Field(default=WatchlistStatus.ACTIVE, description="Tracking status")
|
status: WatchlistStatus = Field(default=WatchlistStatus.ACTIVE)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
poster_image: Optional[str] = Field(None, description="URL to poster image")
|
poster_image: Optional[str] = None
|
||||||
cover_image: Optional[str] = Field(None, description="URL to cover image")
|
cover_image: Optional[str] = None
|
||||||
synopsis: Optional[str] = Field(None, description="Anime synopsis")
|
synopsis: Optional[str] = None
|
||||||
genres: list[str] = Field(default_factory=list, description="Anime genres")
|
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
added_at: datetime = Field(default_factory=datetime.now, description="When added to watchlist")
|
added_at: datetime = Field(default_factory=datetime.now)
|
||||||
updated_at: datetime = Field(default_factory=datetime.now, description="Last update time")
|
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:
|
class Config:
|
||||||
json_encoders = {
|
json_encoders = {
|
||||||
@@ -64,12 +113,10 @@ class WatchlistItemCreate(BaseModel):
|
|||||||
lang: Literal["vostfr", "vf"] = "vostfr"
|
lang: Literal["vostfr", "vf"] = "vostfr"
|
||||||
auto_download: bool = True
|
auto_download: bool = True
|
||||||
quality_preference: QualityPreference = QualityPreference.AUTO
|
quality_preference: QualityPreference = QualityPreference.AUTO
|
||||||
|
|
||||||
# Optional metadata
|
|
||||||
poster_image: Optional[str] = None
|
poster_image: Optional[str] = None
|
||||||
cover_image: Optional[str] = None
|
cover_image: Optional[str] = None
|
||||||
synopsis: Optional[str] = None
|
synopsis: Optional[str] = None
|
||||||
genres: list[str] = []
|
genres: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
class WatchlistItemUpdate(BaseModel):
|
class WatchlistItemUpdate(BaseModel):
|
||||||
@@ -96,26 +143,36 @@ class AutoDownloadResult(BaseModel):
|
|||||||
watchlist_item_id: str
|
watchlist_item_id: str
|
||||||
anime_title: str
|
anime_title: str
|
||||||
new_episodes_found: int
|
new_episodes_found: int
|
||||||
episodes_downloaded: list[int] = Field(default_factory=list)
|
episodes_downloaded: list[int] = PydanticField(default_factory=list)
|
||||||
episodes_failed: list[tuple[int, str]] = Field(default_factory=list) # (episode_number, error_message)
|
episodes_failed: list[tuple[int, str]] = PydanticField(default_factory=list)
|
||||||
checked_at: datetime = Field(default_factory=datetime.now)
|
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):
|
class WatchlistSettings(BaseModel):
|
||||||
"""Global watchlist settings"""
|
"""Global watchlist settings"""
|
||||||
check_interval_hours: int = Field(default=6, ge=1, le=168, description="Check interval (1-168 hours)")
|
check_interval_hours: int = PydanticField(default=6, ge=1, le=168)
|
||||||
auto_download_enabled: bool = Field(default=True, description="Global auto-download toggle")
|
auto_download_enabled: bool = PydanticField(default=True)
|
||||||
max_concurrent_auto_downloads: int = Field(default=2, ge=1, le=10, description="Max concurrent auto-downloads")
|
max_concurrent_auto_downloads: int = PydanticField(default=2, ge=1, le=10)
|
||||||
notify_on_new_episodes: bool = Field(default=False, description="Send notifications for new episodes")
|
notify_on_new_episodes: bool = PydanticField(default=False)
|
||||||
include_completed_anime: bool = Field(default=False, description="Check completed anime too")
|
include_completed_anime: bool = PydanticField(default=False)
|
||||||
|
|
||||||
class Config:
|
# Import UserTable here to resolve SQLModel Relationship mappings
|
||||||
json_schema_extra = {
|
from .auth import UserTable
|
||||||
"example": {
|
|
||||||
"check_interval_hours": 6,
|
|
||||||
"auto_download_enabled": True,
|
|
||||||
"max_concurrent_auto_downloads": 2,
|
|
||||||
"notify_on_new_episodes": False,
|
|
||||||
"include_completed_anime": False
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+91
-26
@@ -3,49 +3,94 @@
|
|||||||
ANIME_PROVIDERS = {
|
ANIME_PROVIDERS = {
|
||||||
"anime-sama": {
|
"anime-sama": {
|
||||||
"name": "Anime-Sama",
|
"name": "Anime-Sama",
|
||||||
"domains": ["anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"],
|
"domains": [
|
||||||
"url_pattern": "https://anime-sama.si/catalogue/{anime}/saison{season}/{lang}/",
|
"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": "🎬",
|
"icon": "🎬",
|
||||||
"color": "#00d9ff"
|
"color": "#00d9ff",
|
||||||
},
|
},
|
||||||
"anime-ultime": {
|
"anime-ultime": {
|
||||||
"name": "Anime-Ultime",
|
"name": "Anime-Ultime",
|
||||||
"domains": ["anime-ultime.net", "anime-ultime.com", "www.anime-ultime.net"],
|
"domains": ["anime-ultime.net", "anime-ultime.com", "www.anime-ultime.net"],
|
||||||
"url_pattern": "https://www.anime-ultime.net/info-{id}-{slug}",
|
"url_pattern": "https://www.anime-ultime.net/info-{id}-{slug}",
|
||||||
"icon": "▶️",
|
"icon": "▶️",
|
||||||
"color": "#00ff88"
|
"color": "#00ff88",
|
||||||
},
|
},
|
||||||
"neko-sama": {
|
"neko-sama": {
|
||||||
"name": "Neko-Sama",
|
"name": "Neko-Sama",
|
||||||
"domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"],
|
"domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"],
|
||||||
"url_pattern": "https://neko-sama.fr/anime/{slug}",
|
"url_pattern": "https://neko-sama.fr/anime/{slug}",
|
||||||
"icon": "🐱",
|
"icon": "🐱",
|
||||||
"color": "#ff6b6b"
|
"color": "#ff6b6b",
|
||||||
},
|
},
|
||||||
"vostfree": {
|
"vostfree": {
|
||||||
"name": "Vostfree",
|
"name": "Vostfree",
|
||||||
"domains": ["vostfree.tv", "www.vostfree.tv"],
|
"domains": ["vostfree.tv", "www.vostfree.tv"],
|
||||||
"url_pattern": "https://vostfree.tv/anime/{slug}",
|
"url_pattern": "https://vostfree.tv/anime/{slug}",
|
||||||
"icon": "📺",
|
"icon": "📺",
|
||||||
"color": "#ffd93d"
|
"color": "#ffd93d",
|
||||||
},
|
},
|
||||||
"french-manga": {
|
"french-manga": {
|
||||||
"name": "French-Manga",
|
"name": "French-Manga",
|
||||||
"domains": ["french-manga.net", "w16.french-manga.net", "w15.french-manga.net", "www.french-manga.net"],
|
"domains": [
|
||||||
|
"french-manga.net",
|
||||||
|
"w16.french-manga.net",
|
||||||
|
"w15.french-manga.net",
|
||||||
|
"www.french-manga.net",
|
||||||
|
],
|
||||||
"url_pattern": "https://w16.french-manga.net/{slug}.html",
|
"url_pattern": "https://w16.french-manga.net/{slug}.html",
|
||||||
"icon": "🇫🇷",
|
"icon": "🇫🇷",
|
||||||
"color": "#ff7675"
|
"color": "#ff7675",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
SERIES_PROVIDERS = {
|
SERIES_PROVIDERS = {
|
||||||
"fs7": {
|
"fs7": {
|
||||||
"name": "French Stream",
|
"name": "French Stream",
|
||||||
"domains": ["fs7.lol", "www.fs7.lol", "french-stream.tv", "www.french-stream.tv"],
|
"domains": [
|
||||||
|
"fs7.lol",
|
||||||
|
"www.fs7.lol",
|
||||||
|
"french-stream.tv",
|
||||||
|
"www.french-stream.tv",
|
||||||
|
"fs7.com",
|
||||||
|
"fs7.net",
|
||||||
|
"fs7.org",
|
||||||
|
"fs7.cc",
|
||||||
|
"fs7.co",
|
||||||
|
"french-stream.com",
|
||||||
|
"french-stream.net",
|
||||||
|
],
|
||||||
"url_pattern": "https://fs7.lol/s-tv/{slug}.html",
|
"url_pattern": "https://fs7.lol/s-tv/{slug}.html",
|
||||||
"icon": "🎬",
|
"icon": "🎬",
|
||||||
"color": "#ff6b9d"
|
"color": "#ff6b9d",
|
||||||
}
|
},
|
||||||
|
"zonetelechargement": {
|
||||||
|
"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 = {
|
FILE_HOSTS = {
|
||||||
@@ -53,92 +98,112 @@ FILE_HOSTS = {
|
|||||||
"name": "1fichier",
|
"name": "1fichier",
|
||||||
"domains": ["1fichier.com", "1fichier.fr"],
|
"domains": ["1fichier.com", "1fichier.fr"],
|
||||||
"icon": "📁",
|
"icon": "📁",
|
||||||
"color": "#4ecdc4"
|
"color": "#4ecdc4",
|
||||||
},
|
},
|
||||||
"uptobox": {
|
"uptobox": {
|
||||||
"name": "Uptobox",
|
"name": "Uptobox",
|
||||||
"domains": ["uptobox.com", "uptobox.fr"],
|
"domains": ["uptobox.com", "uptobox.fr"],
|
||||||
"icon": "📦",
|
"icon": "📦",
|
||||||
"color": "#45b7d1"
|
"color": "#45b7d1",
|
||||||
},
|
},
|
||||||
"doodstream": {
|
"doodstream": {
|
||||||
"name": "Doodstream",
|
"name": "Doodstream",
|
||||||
"domains": ["doodstream.com", "dood.stream", "dood.to", "dood.lol", "dood.cx", "dood.so", "dood.watch", "dood.sh"],
|
"domains": [
|
||||||
|
"doodstream.com",
|
||||||
|
"dood.stream",
|
||||||
|
"dood.to",
|
||||||
|
"dood.lol",
|
||||||
|
"dood.cx",
|
||||||
|
"dood.so",
|
||||||
|
"dood.watch",
|
||||||
|
"dood.sh",
|
||||||
|
],
|
||||||
"icon": "🎥",
|
"icon": "🎥",
|
||||||
"color": "#f7b731"
|
"color": "#f7b731",
|
||||||
},
|
},
|
||||||
"rapidfile": {
|
"rapidfile": {
|
||||||
"name": "Rapidfile",
|
"name": "Rapidfile",
|
||||||
"domains": ["rapidfile.net", "rapidfile.com"],
|
"domains": ["rapidfile.net", "rapidfile.com"],
|
||||||
"icon": "⚡",
|
"icon": "⚡",
|
||||||
"color": "#ff6b6b"
|
"color": "#ff6b6b",
|
||||||
},
|
},
|
||||||
"vidmoly": {
|
"vidmoly": {
|
||||||
"name": "VidMoly",
|
"name": "VidMoly",
|
||||||
"domains": ["vidmoly.to", "vidmoly.org", "vidmoly.biz"],
|
"domains": ["vidmoly.to", "vidmoly.org", "vidmoly.biz"],
|
||||||
"icon": "🎬",
|
"icon": "🎬",
|
||||||
"color": "#a29bfe"
|
"color": "#a29bfe",
|
||||||
},
|
},
|
||||||
"sendvid": {
|
"sendvid": {
|
||||||
"name": "SendVid",
|
"name": "SendVid",
|
||||||
"domains": ["sendvid.com", "sendvid.io"],
|
"domains": ["sendvid.com", "sendvid.io"],
|
||||||
"icon": "📤",
|
"icon": "📤",
|
||||||
"color": "#fd79a8"
|
"color": "#fd79a8",
|
||||||
},
|
},
|
||||||
"sibnet": {
|
"sibnet": {
|
||||||
"name": "Sibnet",
|
"name": "Sibnet",
|
||||||
"domains": ["sibnet.ru", "video.sibnet.ru"],
|
"domains": ["sibnet.ru", "video.sibnet.ru"],
|
||||||
"icon": "🎞️",
|
"icon": "🎞️",
|
||||||
"color": "#00cec9"
|
"color": "#00cec9",
|
||||||
},
|
},
|
||||||
"lpayer": {
|
"lpayer": {
|
||||||
"name": "Lplayer",
|
"name": "Lplayer",
|
||||||
"domains": ["lpayer.embed4me.com", "lpayer.com", "lplayer.fr"],
|
"domains": ["lpayer.embed4me.com", "lpayer.com", "lplayer.fr"],
|
||||||
"icon": "▶️",
|
"icon": "▶️",
|
||||||
"color": "#e17055"
|
"color": "#e17055",
|
||||||
},
|
},
|
||||||
"vidzy": {
|
"vidzy": {
|
||||||
"name": "Vidzy",
|
"name": "Vidzy",
|
||||||
"domains": ["vidzy.com", "vidzy.net", "www.vidzy.com"],
|
"domains": ["vidzy.com", "vidzy.net", "www.vidzy.com"],
|
||||||
"icon": "🎞️",
|
"icon": "🎞️",
|
||||||
"color": "#74b9ff"
|
"color": "#74b9ff",
|
||||||
},
|
},
|
||||||
"luluv": {
|
"luluv": {
|
||||||
"name": "LuLuvid",
|
"name": "LuLuvid",
|
||||||
"domains": ["luluv.com", "luluvid.com", "www.luluv.com", "www.luluvid.com"],
|
"domains": ["luluv.com", "luluvid.com", "www.luluv.com", "www.luluvid.com"],
|
||||||
"icon": "🎬",
|
"icon": "🎬",
|
||||||
"color": "#a29bfe"
|
"color": "#a29bfe",
|
||||||
},
|
},
|
||||||
"uqload": {
|
"uqload": {
|
||||||
"name": "Uqload",
|
"name": "Uqload",
|
||||||
"domains": ["uqload.bz", "uqload.com", "www.uqload.bz", "www.uqload.com"],
|
"domains": ["uqload.bz", "uqload.com", "www.uqload.bz", "www.uqload.com"],
|
||||||
"icon": "📺",
|
"icon": "📺",
|
||||||
"color": "#fd79a8"
|
"color": "#fd79a8",
|
||||||
}
|
},
|
||||||
|
"smoothpre": {
|
||||||
|
"name": "Smoothpre",
|
||||||
|
"domains": ["smoothpre.com", "www.smoothpre.com"],
|
||||||
|
"icon": "🎬",
|
||||||
|
"color": "#a29bfe",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_all_providers():
|
def get_all_providers():
|
||||||
"""Get all supported providers (anime + series + file hosts)"""
|
"""Get all supported providers (anime + series + file hosts)"""
|
||||||
return {**ANIME_PROVIDERS, **SERIES_PROVIDERS, **FILE_HOSTS}
|
return {**ANIME_PROVIDERS, **SERIES_PROVIDERS, **FILE_HOSTS}
|
||||||
|
|
||||||
|
|
||||||
def get_anime_providers():
|
def get_anime_providers():
|
||||||
"""Get all anime streaming providers"""
|
"""Get all anime streaming providers"""
|
||||||
return ANIME_PROVIDERS
|
return ANIME_PROVIDERS
|
||||||
|
|
||||||
|
|
||||||
def get_series_providers():
|
def get_series_providers():
|
||||||
"""Get all series streaming providers"""
|
"""Get all series streaming providers"""
|
||||||
return SERIES_PROVIDERS
|
return SERIES_PROVIDERS
|
||||||
|
|
||||||
|
|
||||||
def get_file_hosts():
|
def get_file_hosts():
|
||||||
"""Get all file hosting providers"""
|
"""Get all file hosting providers"""
|
||||||
return FILE_HOSTS
|
return FILE_HOSTS
|
||||||
|
|
||||||
|
|
||||||
def detect_provider_from_url(url: str) -> str | None:
|
def detect_provider_from_url(url: str) -> str | None:
|
||||||
"""Detect which provider can handle the given URL"""
|
"""Detect which provider can handle the given URL"""
|
||||||
url_lower = url.lower()
|
url_lower = url.lower()
|
||||||
|
|
||||||
for provider_id, provider in get_all_providers().items():
|
for provider_id, provider in get_all_providers().items():
|
||||||
for domain in provider['domains']:
|
for domain in provider["domains"]:
|
||||||
if domain in url_lower:
|
if domain in url_lower:
|
||||||
return provider_id
|
return provider_id
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""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,
|
||||||
|
NekoSamaDownloader,
|
||||||
|
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),
|
||||||
|
("neko-sama", NekoSamaDownloader, ANIME_PROVIDERS),
|
||||||
|
("anime-ultime", AnimeUltimeDownloader, ANIME_PROVIDERS),
|
||||||
|
("vostfree", VostfreeDownloader, ANIME_PROVIDERS),
|
||||||
|
("french-manga", FrenchMangaDownloader, ANIME_PROVIDERS),
|
||||||
|
("fs7", FS7Downloader, SERIES_PROVIDERS),
|
||||||
|
("zonetelechargement", ZoneTelechargementDownloader, SERIES_PROVIDERS),
|
||||||
|
]
|
||||||
|
|
||||||
|
for provider_id, provider_class, provider_dict in provider_classes:
|
||||||
|
if provider_id in provider_dict:
|
||||||
|
try:
|
||||||
|
self.providers[provider_id] = provider_class()
|
||||||
|
self.provider_info[provider_id] = provider_dict[provider_id]
|
||||||
|
self.health_status[provider_id] = {
|
||||||
|
"status": "unknown",
|
||||||
|
"last_check": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
logger.info(f"Loaded hardcoded provider: {provider_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load provider {provider_id}: {e}")
|
||||||
|
|
||||||
|
async def check_all_health(self):
|
||||||
|
"""Check health of all registered providers"""
|
||||||
|
logger.info("Checking health of all providers...")
|
||||||
|
tasks = []
|
||||||
|
for provider_id, scraper in self.providers.items():
|
||||||
|
tasks.append(self._check_single_health(provider_id, scraper))
|
||||||
|
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
logger.info("Provider health check complete")
|
||||||
|
|
||||||
|
async def _check_single_health(self, provider_id: str, scraper):
|
||||||
|
"""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")
|
||||||
|
return len(results) > 0
|
||||||
|
elif hasattr(scraper, "search"):
|
||||||
|
results = await scraper.search("One Piece")
|
||||||
|
return len(results) > 0
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Health check exception for {getattr(scraper, 'provider_id', scraper)}: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_provider(self, provider_id: str):
|
||||||
|
return self.providers.get(provider_id)
|
||||||
|
|
||||||
|
def get_active_providers(self) -> List:
|
||||||
|
"""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):
|
if not any(anime_lower == dl.lower() for dl in downloaded_anime):
|
||||||
recommendations.append({
|
recommendations.append({
|
||||||
**anime,
|
**anime,
|
||||||
|
'cover_image': anime.get('cover_image'),
|
||||||
'recommendation_reason': f"Similaire à {anime_name}",
|
'recommendation_reason': f"Similaire à {anime_name}",
|
||||||
'relevance_score': 0.9
|
'relevance_score': 0.9
|
||||||
})
|
})
|
||||||
@@ -237,6 +238,7 @@ class RecommendationEngine:
|
|||||||
|
|
||||||
recommendations.append({
|
recommendations.append({
|
||||||
**anime,
|
**anime,
|
||||||
|
'cover_image': anime.get('cover_image'),
|
||||||
'recommendation_reason': 'Nouveau de la saison' + (' (vos genres!)' if genre_match else ''),
|
'recommendation_reason': 'Nouveau de la saison' + (' (vos genres!)' if genre_match else ''),
|
||||||
'relevance_score': 0.8 if genre_match else 0.6
|
'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:
|
async def _rate_limited_request(self, url: str) -> httpx.Response:
|
||||||
"""Make a rate-limited request to Jikan API"""
|
"""Make a rate-limited request to Jikan API"""
|
||||||
# Enforce minimum delay between requests
|
|
||||||
if self._last_request_time:
|
if self._last_request_time:
|
||||||
elapsed = (datetime.now() - self._last_request_time).total_seconds()
|
elapsed = (datetime.now() - self._last_request_time).total_seconds()
|
||||||
if elapsed < self._min_request_interval:
|
if elapsed < self._min_request_interval:
|
||||||
await asyncio.sleep(self._min_request_interval - elapsed)
|
await asyncio.sleep(self._min_request_interval - elapsed)
|
||||||
|
|
||||||
# Retry logic with exponential backoff
|
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
base_delay = 1.0
|
base_delay = 1.0
|
||||||
|
|
||||||
@@ -37,7 +35,6 @@ class AnimeReleasesFetcher:
|
|||||||
response = await self.client.get(url)
|
response = await self.client.get(url)
|
||||||
self._last_request_time = datetime.now()
|
self._last_request_time = datetime.now()
|
||||||
|
|
||||||
# Handle rate limiting (HTTP 429)
|
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
delay = base_delay * (2 ** attempt)
|
delay = base_delay * (2 ** attempt)
|
||||||
@@ -58,31 +55,35 @@ class AnimeReleasesFetcher:
|
|||||||
else:
|
else:
|
||||||
raise Exception(f"Request timeout after {max_retries} retries") from e
|
raise Exception(f"Request timeout after {max_retries} retries") from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# For any other exception, don't retry
|
|
||||||
raise
|
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):
|
async def _get_cached(self, key: str, fetcher):
|
||||||
"""Get cached result or fetch new data"""
|
"""Get cached result or fetch new data"""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
|
||||||
if key in self._cache and key in self._cache_time:
|
if key in self._cache and key in self._cache_time:
|
||||||
if now - self._cache_time[key] < self._cache_duration:
|
if now - self._cache_time[key] < self._cache_duration:
|
||||||
return self._cache[key]
|
return self._cache[key]
|
||||||
|
|
||||||
# Fetch new data
|
|
||||||
result = await fetcher()
|
result = await fetcher()
|
||||||
self._cache[key] = result
|
self._cache[key] = result
|
||||||
self._cache_time[key] = now
|
self._cache_time[key] = now
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def get_seasonal_anime(self, year: Optional[int] = None, season: Optional[str] = None) -> List[Dict]:
|
async def get_seasonal_anime(self, year: Optional[int] = None, season: Optional[str] = None) -> List[Dict]:
|
||||||
"""
|
"""Get current season anime from Jikan API"""
|
||||||
Get current season anime from Jikan API
|
|
||||||
|
|
||||||
Args:
|
|
||||||
year: Year (defaults to current year)
|
|
||||||
season: Season (winter, spring, summer, fall)
|
|
||||||
"""
|
|
||||||
async def fetch():
|
async def fetch():
|
||||||
nonlocal local_year, local_season
|
nonlocal local_year, local_season
|
||||||
try:
|
try:
|
||||||
@@ -101,41 +102,29 @@ class AnimeReleasesFetcher:
|
|||||||
'score': anime.get('score', 0),
|
'score': anime.get('score', 0),
|
||||||
'genres': [g.get('name') for g in anime.get('genres', [])],
|
'genres': [g.get('name') for g in anime.get('genres', [])],
|
||||||
'synopsis': anime.get('synopsis', ''),
|
'synopsis': anime.get('synopsis', ''),
|
||||||
|
'cover_image': self._extract_cover_image(anime),
|
||||||
'images': anime.get('images', {}),
|
'images': anime.get('images', {}),
|
||||||
'url': anime.get('url', ''),
|
'url': anime.get('url', ''),
|
||||||
'mal_id': anime.get('mal_id')
|
'mal_id': anime.get('mal_id')
|
||||||
})
|
})
|
||||||
|
|
||||||
return anime_list
|
return anime_list
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching seasonal anime: {e}", exc_info=True)
|
logger.error(f"Error fetching seasonal anime: {e}", exc_info=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Initialize local variables
|
|
||||||
local_year = year if year else datetime.now().year
|
local_year = year if year else datetime.now().year
|
||||||
local_season = season
|
local_season = season
|
||||||
|
|
||||||
if not local_season:
|
if not local_season:
|
||||||
month = datetime.now().month
|
month = datetime.now().month
|
||||||
if month in [12, 1, 2]:
|
if month in [12, 1, 2]: local_season = "winter"
|
||||||
local_season = "winter"
|
elif month in [3, 4, 5]: local_season = "spring"
|
||||||
elif month in [3, 4, 5]:
|
elif month in [6, 7, 8]: local_season = "summer"
|
||||||
local_season = "spring"
|
else: local_season = "fall"
|
||||||
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)
|
return await self._get_cached(f"seasonal_{local_year}_{local_season}", fetch)
|
||||||
|
|
||||||
async def get_scheduled_anime(self, day: Optional[str] = None) -> List[Dict]:
|
async def get_scheduled_anime(self, day: Optional[str] = None) -> List[Dict]:
|
||||||
"""
|
"""Get anime scheduled for a specific day"""
|
||||||
Get anime scheduled for a specific day
|
|
||||||
|
|
||||||
Args:
|
|
||||||
day: Day of the week (monday, tuesday, etc.)
|
|
||||||
"""
|
|
||||||
async def fetch():
|
async def fetch():
|
||||||
nonlocal local_day
|
nonlocal local_day
|
||||||
try:
|
try:
|
||||||
@@ -151,34 +140,25 @@ class AnimeReleasesFetcher:
|
|||||||
'score': anime.get('score', 0),
|
'score': anime.get('score', 0),
|
||||||
'genres': [g.get('name') for g in anime.get('genres', [])],
|
'genres': [g.get('name') for g in anime.get('genres', [])],
|
||||||
'synopsis': anime.get('synopsis', ''),
|
'synopsis': anime.get('synopsis', ''),
|
||||||
|
'cover_image': self._extract_cover_image(anime),
|
||||||
'broadcast': anime.get('broadcast', {}),
|
'broadcast': anime.get('broadcast', {}),
|
||||||
'url': anime.get('url', ''),
|
'url': anime.get('url', ''),
|
||||||
'mal_id': anime.get('mal_id')
|
'mal_id': anime.get('mal_id')
|
||||||
})
|
})
|
||||||
|
|
||||||
return anime_list
|
return anime_list
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching scheduled anime: {e}", exc_info=True)
|
logger.error(f"Error fetching scheduled anime: {e}", exc_info=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Initialize local variable
|
|
||||||
local_day = day
|
local_day = day
|
||||||
if not local_day:
|
if not local_day:
|
||||||
days = ['monday', 'tuesday', 'wednesday', 'thursday',
|
days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||||
'friday', 'saturday', 'sunday']
|
|
||||||
local_day = days[datetime.now().weekday()]
|
local_day = days[datetime.now().weekday()]
|
||||||
|
|
||||||
return await self._get_cached(f"scheduled_{local_day}", fetch)
|
return await self._get_cached(f"scheduled_{local_day}", fetch)
|
||||||
|
|
||||||
async def get_top_anime(self, type: str = "tv", limit: int = 15) -> List[Dict]:
|
async def get_top_anime(self, type: str = "tv", limit: int = 15) -> List[Dict]:
|
||||||
"""
|
"""Get top anime"""
|
||||||
Get top anime
|
|
||||||
|
|
||||||
Args:
|
|
||||||
type: Type of anime (tv, movie, etc.)
|
|
||||||
limit: Number of results
|
|
||||||
"""
|
|
||||||
async def fetch():
|
async def fetch():
|
||||||
try:
|
try:
|
||||||
url = f"{self.jikan_base}/top/anime?type={type}&limit={limit}"
|
url = f"{self.jikan_base}/top/anime?type={type}&limit={limit}"
|
||||||
@@ -195,13 +175,12 @@ class AnimeReleasesFetcher:
|
|||||||
'rank': anime.get('rank', 0),
|
'rank': anime.get('rank', 0),
|
||||||
'genres': [g.get('name') for g in anime.get('genres', [])],
|
'genres': [g.get('name') for g in anime.get('genres', [])],
|
||||||
'synopsis': anime.get('synopsis', ''),
|
'synopsis': anime.get('synopsis', ''),
|
||||||
|
'cover_image': self._extract_cover_image(anime),
|
||||||
'images': anime.get('images', {}),
|
'images': anime.get('images', {}),
|
||||||
'url': anime.get('url', ''),
|
'url': anime.get('url', ''),
|
||||||
'mal_id': anime.get('mal_id')
|
'mal_id': anime.get('mal_id')
|
||||||
})
|
})
|
||||||
|
|
||||||
return anime_list
|
return anime_list
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching top anime: {e}", exc_info=True)
|
logger.error(f"Error fetching top anime: {e}", exc_info=True)
|
||||||
return []
|
return []
|
||||||
@@ -209,25 +188,15 @@ class AnimeReleasesFetcher:
|
|||||||
return await self._get_cached(f"top_{type}_{limit}", fetch)
|
return await self._get_cached(f"top_{type}_{limit}", fetch)
|
||||||
|
|
||||||
async def search_anime(self, query: str, limit: int = 10) -> List[Dict]:
|
async def search_anime(self, query: str, limit: int = 10) -> List[Dict]:
|
||||||
"""
|
"""Search for anime by name"""
|
||||||
Search for anime by name
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
limit: Number of results
|
|
||||||
"""
|
|
||||||
async def fetch():
|
async def fetch():
|
||||||
try:
|
try:
|
||||||
url = f"{self.jikan_base}/anime?q={query}&limit={limit}"
|
url = f"{self.jikan_base}/anime?q={query}&limit={limit}"
|
||||||
response = await self._rate_limited_request(url)
|
response = await self._rate_limited_request(url)
|
||||||
|
|
||||||
# Check HTTP status
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"Jikan API returned status {response.status_code} for query '{query}'")
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
anime_list = []
|
anime_list = []
|
||||||
for anime in data.get('data', []):
|
for anime in data.get('data', []):
|
||||||
anime_list.append({
|
anime_list.append({
|
||||||
@@ -237,138 +206,41 @@ class AnimeReleasesFetcher:
|
|||||||
'score': anime.get('score', 0),
|
'score': anime.get('score', 0),
|
||||||
'genres': [g.get('name') for g in anime.get('genres', [])],
|
'genres': [g.get('name') for g in anime.get('genres', [])],
|
||||||
'synopsis': anime.get('synopsis', ''),
|
'synopsis': anime.get('synopsis', ''),
|
||||||
|
'cover_image': self._extract_cover_image(anime),
|
||||||
'images': anime.get('images', {}),
|
'images': anime.get('images', {}),
|
||||||
'url': anime.get('url', ''),
|
'url': anime.get('url', ''),
|
||||||
'mal_id': anime.get('mal_id')
|
'mal_id': anime.get('mal_id')
|
||||||
})
|
})
|
||||||
|
|
||||||
return anime_list
|
return anime_list
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error searching anime for query '{query}': {e}", exc_info=True)
|
logger.error(f"Error searching anime for query '{query}': {e}", exc_info=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Don't cache searches
|
|
||||||
return await fetch()
|
return await fetch()
|
||||||
|
|
||||||
async def get_anime_details(self, mal_id: int) -> Optional[Dict]:
|
async def get_anime_details(self, mal_id: int) -> Optional[Dict]:
|
||||||
"""
|
"""Get full details of an anime"""
|
||||||
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
|
|
||||||
"""
|
|
||||||
async def fetch():
|
async def fetch():
|
||||||
try:
|
try:
|
||||||
# Get anime details
|
|
||||||
url = f"{self.jikan_base}/anime/{mal_id}/full"
|
url = f"{self.jikan_base}/anime/{mal_id}/full"
|
||||||
response = await self._rate_limited_request(url)
|
response = await self._rate_limited_request(url)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
if 'data' not in data: return None
|
||||||
if 'data' not in data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
anime = data['data']
|
anime = data['data']
|
||||||
|
|
||||||
# Extract basic info
|
return {
|
||||||
anime_details = {
|
|
||||||
'mal_id': anime.get('mal_id'),
|
'mal_id': anime.get('mal_id'),
|
||||||
'title': anime.get('title'),
|
'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', ''),
|
'synopsis': anime.get('synopsis', ''),
|
||||||
'background': anime.get('background', ''),
|
'cover_image': self._extract_cover_image(anime),
|
||||||
'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', {}),
|
|
||||||
'images': anime.get('images', {}),
|
'images': anime.get('images', {}),
|
||||||
'trailer': anime.get('trailer', {}),
|
|
||||||
'url': anime.get('url', ''),
|
'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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching anime details for MAL ID {mal_id}: {e}", exc_info=True)
|
logger.error(f"Error fetching anime details for MAL ID {mal_id}: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
@@ -376,62 +248,26 @@ class AnimeReleasesFetcher:
|
|||||||
return await self._get_cached(f"anime_details_{mal_id}", fetch)
|
return await self._get_cached(f"anime_details_{mal_id}", fetch)
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Close the HTTP client"""
|
|
||||||
await self.client.aclose()
|
await self.client.aclose()
|
||||||
|
|
||||||
|
|
||||||
async def get_latest_releases_with_info(limit: int = 20) -> List[Dict]:
|
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()
|
fetcher = AnimeReleasesFetcher()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get current season anime
|
|
||||||
seasonal = await fetcher.get_seasonal_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()
|
scheduled = await fetcher.get_scheduled_anime()
|
||||||
logger.info(f"Found {len(scheduled)} scheduled anime")
|
|
||||||
|
|
||||||
# Combine and deduplicate
|
|
||||||
all_anime = {}
|
all_anime = {}
|
||||||
|
|
||||||
for anime in seasonal:
|
for anime in seasonal:
|
||||||
all_anime[anime['mal_id']] = {
|
all_anime[anime['mal_id']] = {**anime, 'source': 'seasonal'}
|
||||||
**anime,
|
|
||||||
'source': 'seasonal',
|
|
||||||
'release_type': 'current_season'
|
|
||||||
}
|
|
||||||
|
|
||||||
for anime in scheduled:
|
for anime in scheduled:
|
||||||
if anime['mal_id'] not in all_anime:
|
if anime['mal_id'] not in all_anime:
|
||||||
all_anime[anime['mal_id']] = {
|
all_anime[anime['mal_id']] = {**anime, 'source': 'scheduled'}
|
||||||
**anime,
|
releases = sorted(all_anime.values(), key=lambda x: x.get('score') or 0, reverse=True)
|
||||||
'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
|
|
||||||
if not releases:
|
if not releases:
|
||||||
logger.warning("No releases found, trying top anime")
|
|
||||||
releases = await fetcher.get_top_anime(limit=limit)
|
releases = await fetcher.get_top_anime(limit=limit)
|
||||||
|
|
||||||
return releases[:limit]
|
return releases[:limit]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting latest releases: {e}", exc_info=True)
|
logger.error(f"Error getting latest releases: {e}", exc_info=True)
|
||||||
# Return empty list on error
|
|
||||||
return []
|
return []
|
||||||
finally:
|
finally:
|
||||||
await fetcher.close()
|
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,538 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
NekoSamaDownloader,
|
||||||
|
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(background_tasks: BackgroundTasks):
|
||||||
|
"""Trigger a manual health check of all providers in the background"""
|
||||||
|
from app.auto_download_scheduler import auto_download_scheduler
|
||||||
|
|
||||||
|
background_tasks.add_task(auto_download_scheduler.trigger_health_check_now)
|
||||||
|
return {"status": "Health check triggered in background"}
|
||||||
|
|
||||||
|
|
||||||
|
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(),
|
||||||
|
"neko-sama": NekoSamaDownloader(),
|
||||||
|
"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
|
||||||
|
|
||||||
|
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 from scrapers (not Kitsu — Kitsu is anime-only)
|
||||||
|
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")
|
||||||
|
# Enrich top 10 results with metadata from the scraper itself
|
||||||
|
downloader = series_downloaders.get(provider_id)
|
||||||
|
if downloader and hasattr(downloader, "get_anime_metadata"):
|
||||||
|
for idx, item in enumerate(result[:10]):
|
||||||
|
if isinstance(item, dict) and item.get("url"):
|
||||||
|
enrichment_tasks.append(
|
||||||
|
downloader.get_anime_metadata(item["url"])
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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,352 @@
|
|||||||
|
"""
|
||||||
|
Recommendations and releases routes for Ohm Stream Downloader API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Query, HTTPException, Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.recommendation_engine import RecommendationEngine
|
||||||
|
from app.models.auth import User
|
||||||
|
from app.models.settings import AppSettingsTable
|
||||||
|
from app.database import get_session
|
||||||
|
from app.routers.router_auth import get_optional_user, get_current_user_from_token
|
||||||
|
from app.routers.router_settings import _compute_auto_weights
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _get_effective_weights(session: Session, user_id: str) -> tuple:
|
||||||
|
"""Return (anime_enabled, series_enabled, anime_weight, series_weight)."""
|
||||||
|
settings = session.exec(
|
||||||
|
select(AppSettingsTable).where(AppSettingsTable.user_id == user_id)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if settings is None:
|
||||||
|
return True, True, 1, 1
|
||||||
|
|
||||||
|
anime_enabled = getattr(settings, 'anime_enabled', True)
|
||||||
|
series_enabled = getattr(settings, 'series_enabled', True)
|
||||||
|
mode = getattr(settings, 'content_weight_mode', 'auto')
|
||||||
|
download_dir = getattr(settings, 'download_dir', 'downloads')
|
||||||
|
|
||||||
|
if mode == "auto":
|
||||||
|
weights = _compute_auto_weights(download_dir)
|
||||||
|
return anime_enabled, series_enabled, weights["anime_weight"], weights["series_weight"]
|
||||||
|
else:
|
||||||
|
aw = getattr(settings, 'content_weight_anime', 2)
|
||||||
|
sw = getattr(settings, 'content_weight_series', 1)
|
||||||
|
return anime_enabled, series_enabled, int(aw), int(sw)
|
||||||
|
|
||||||
|
|
||||||
|
def _weighted_mix(items_a: List[Dict], items_b: List[Dict], limit: int,
|
||||||
|
weight_a: int = 2, weight_b: int = 1) -> List[Dict]:
|
||||||
|
"""Mix two lists using weights. Distributes items proportionally and interleaves.
|
||||||
|
|
||||||
|
If weight_a=2, weight_b=1 and limit=15:
|
||||||
|
- slots_a ≈ 10, slots_b ≈ 5
|
||||||
|
- B items are spaced evenly across the list
|
||||||
|
If one list is shorter, the other fills remaining slots.
|
||||||
|
"""
|
||||||
|
total_weight = weight_a + weight_b
|
||||||
|
if total_weight == 0:
|
||||||
|
return (items_a + items_b)[:limit]
|
||||||
|
|
||||||
|
slots_a = round(limit * weight_a / total_weight)
|
||||||
|
slots_b = limit - slots_a
|
||||||
|
|
||||||
|
pick_a = min(slots_a, len(items_a))
|
||||||
|
pick_b = min(slots_b, len(items_b))
|
||||||
|
|
||||||
|
# Redistribute unfilled slots
|
||||||
|
if pick_a < slots_a:
|
||||||
|
pick_b = min(pick_b + (slots_a - pick_a), len(items_b))
|
||||||
|
elif pick_b < slots_b:
|
||||||
|
pick_a = min(pick_a + (slots_b - pick_b), len(items_a))
|
||||||
|
|
||||||
|
a = items_a[:pick_a]
|
||||||
|
b = items_b[:pick_b]
|
||||||
|
|
||||||
|
total = pick_a + pick_b
|
||||||
|
if total == 0:
|
||||||
|
return []
|
||||||
|
if pick_b == 0:
|
||||||
|
return a[:limit]
|
||||||
|
if pick_a == 0:
|
||||||
|
return b[:limit]
|
||||||
|
|
||||||
|
# Place B items at evenly spaced positions, fill gaps with A
|
||||||
|
result = [None] * total
|
||||||
|
for i, item in enumerate(b):
|
||||||
|
pos = round(i * (total - 1) / max(pick_b - 1, 1))
|
||||||
|
result[pos] = item
|
||||||
|
a_idx = 0
|
||||||
|
for i in range(total):
|
||||||
|
if result[i] is None:
|
||||||
|
result[i] = a[a_idx]
|
||||||
|
a_idx += 1
|
||||||
|
|
||||||
|
return result[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
@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),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Get personalized recommendations based on user settings (anime + series)"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if anime_enabled:
|
||||||
|
engine = RecommendationEngine(download_dir="downloads")
|
||||||
|
try:
|
||||||
|
anime_recs = await engine.get_personalized_recommendations(limit=limit)
|
||||||
|
for r in anime_recs:
|
||||||
|
r['content_type'] = 'anime'
|
||||||
|
recommendations.extend(anime_recs)
|
||||||
|
finally:
|
||||||
|
await engine.close()
|
||||||
|
|
||||||
|
if series_enabled:
|
||||||
|
try:
|
||||||
|
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||||
|
downloader = FS7Downloader()
|
||||||
|
series_recs = await downloader.get_latest_series(limit=limit)
|
||||||
|
for r in series_recs:
|
||||||
|
r['content_type'] = 'series'
|
||||||
|
recommendations.extend(series_recs)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Series recommendations fetch failed: {e}")
|
||||||
|
|
||||||
|
if content_type and content_type != "all":
|
||||||
|
recommendations = [r for r in recommendations if r.get("content_type") == content_type]
|
||||||
|
else:
|
||||||
|
anime_items = [r for r in recommendations if r.get("content_type") == "anime"]
|
||||||
|
series_items = [r for r in recommendations if r.get("content_type") == "series"]
|
||||||
|
recommendations = _weighted_mix(anime_items, series_items, limit,
|
||||||
|
weight_a=anime_weight, weight_b=series_weight)
|
||||||
|
|
||||||
|
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:
|
||||||
|
logger.error(f"Recommendations error: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@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"),
|
||||||
|
current_user: Optional[User] = Depends(get_optional_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Get latest releases based on user settings (anime + series)"""
|
||||||
|
from app.recommendations import get_latest_releases_with_info
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
|
||||||
|
releases = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if anime_enabled:
|
||||||
|
anime_releases = await get_latest_releases_with_info(limit=limit)
|
||||||
|
for r in anime_releases:
|
||||||
|
r['content_type'] = 'anime'
|
||||||
|
releases.extend(anime_releases)
|
||||||
|
|
||||||
|
if series_enabled:
|
||||||
|
try:
|
||||||
|
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||||
|
downloader = FS7Downloader()
|
||||||
|
series_releases = await downloader.get_latest_series(limit=limit)
|
||||||
|
for r in series_releases:
|
||||||
|
r['content_type'] = 'series'
|
||||||
|
releases.extend(series_releases)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Series releases fetch failed: {e}")
|
||||||
|
|
||||||
|
if content_type and content_type != "all":
|
||||||
|
releases = [r for r in releases if r.get("content_type") == content_type]
|
||||||
|
else:
|
||||||
|
anime_items = [r for r in releases if r.get("content_type") == "anime"]
|
||||||
|
series_items = [r for r in releases if r.get("content_type") == "series"]
|
||||||
|
releases = _weighted_mix(anime_items, series_items, limit,
|
||||||
|
weight_a=anime_weight, weight_b=series_weight)
|
||||||
|
|
||||||
|
if html or is_htmx:
|
||||||
|
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:
|
||||||
|
logger.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()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/series/latest")
|
||||||
|
async def get_latest_series(
|
||||||
|
request: Request,
|
||||||
|
limit: int = 20,
|
||||||
|
html: bool = Query(False),
|
||||||
|
current_user: Optional[User] = Depends(get_optional_user),
|
||||||
|
):
|
||||||
|
"""Get latest TV series releases from FS7 homepage"""
|
||||||
|
if current_user is None and (html or request.headers.get("HX-Request")):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"components/login_prompt.html", {"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||||
|
|
||||||
|
downloader = FS7Downloader()
|
||||||
|
series = await downloader.get_latest_series(limit=limit)
|
||||||
|
|
||||||
|
if html or request.headers.get("HX-Request"):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"components/series_releases_list.html",
|
||||||
|
{"request": request, "releases": series}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"releases": series,
|
||||||
|
"count": len(series),
|
||||||
|
"updated": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Latest series error: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -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,299 @@
|
|||||||
|
"""Application settings routes for Ohm Stream Downloader API"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_auto_weights(download_dir: str) -> Dict[str, Any]:
|
||||||
|
"""Analyze downloaded files to compute anime vs series ratio.
|
||||||
|
|
||||||
|
Uses filename conventions:
|
||||||
|
- Series: contains "Saison" or "Season" keywords
|
||||||
|
- Anime: everything else in the downloads folder
|
||||||
|
Returns dict with counts and computed weights.
|
||||||
|
"""
|
||||||
|
base = Path(download_dir)
|
||||||
|
if not base.exists():
|
||||||
|
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
||||||
|
|
||||||
|
anime_count = 0
|
||||||
|
series_count = 0
|
||||||
|
|
||||||
|
for f in base.rglob("*"):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
if f.suffix.lower() not in (".mp4", ".mkv", ".avi", ".wmv", ".flv", ".mov"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = f.stem.lower()
|
||||||
|
# Heuristic: series TV files often have "Saison" or "Season" + number
|
||||||
|
# Anime files rarely use this format (they use "Episode" or "S01E01")
|
||||||
|
import re
|
||||||
|
if re.search(r'(?:saison|season)\s*\d+', name):
|
||||||
|
series_count += 1
|
||||||
|
else:
|
||||||
|
anime_count += 1
|
||||||
|
|
||||||
|
total = anime_count + series_count
|
||||||
|
if total == 0:
|
||||||
|
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
||||||
|
|
||||||
|
# Compute weights: proportional to download count, minimum 1
|
||||||
|
if anime_count == 0:
|
||||||
|
aw, sw = 0, 1
|
||||||
|
elif series_count == 0:
|
||||||
|
aw, sw = 1, 0
|
||||||
|
else:
|
||||||
|
# Keep weights small (max 5) for reasonable interleaving
|
||||||
|
ratio = anime_count / series_count
|
||||||
|
if ratio >= 4:
|
||||||
|
aw, sw = 4, 1
|
||||||
|
elif ratio >= 2:
|
||||||
|
aw, sw = 2, 1
|
||||||
|
elif ratio >= 1:
|
||||||
|
aw, sw = 1, 1
|
||||||
|
elif ratio >= 0.5:
|
||||||
|
aw, sw = 1, 2
|
||||||
|
else:
|
||||||
|
aw, sw = 1, 4
|
||||||
|
|
||||||
|
return {
|
||||||
|
"anime_count": anime_count,
|
||||||
|
"series_count": series_count,
|
||||||
|
"anime_weight": aw,
|
||||||
|
"series_weight": sw,
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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'),
|
||||||
|
content_weight_mode=getattr(settings_obj, 'content_weight_mode', 'auto'),
|
||||||
|
content_weight_anime=getattr(settings_obj, 'content_weight_anime', 2),
|
||||||
|
content_weight_series=getattr(settings_obj, 'content_weight_series', 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
if update_data.content_weight_mode is not None:
|
||||||
|
settings_obj.content_weight_mode = update_data.content_weight_mode
|
||||||
|
if update_data.content_weight_anime is not None:
|
||||||
|
settings_obj.content_weight_anime = update_data.content_weight_anime
|
||||||
|
if update_data.content_weight_series is not None:
|
||||||
|
settings_obj.content_weight_series = update_data.content_weight_series
|
||||||
|
|
||||||
|
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("/content-weight")
|
||||||
|
async def get_content_weight(
|
||||||
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Get current effective content weights (auto-computed or manual)"""
|
||||||
|
statement = select(AppSettingsTable).where(
|
||||||
|
AppSettingsTable.user_id == current_user.id
|
||||||
|
)
|
||||||
|
settings_obj = session.exec(statement).first()
|
||||||
|
download_dir = getattr(settings_obj, 'download_dir', 'downloads') if settings_obj else 'downloads'
|
||||||
|
mode = getattr(settings_obj, 'content_weight_mode', 'auto') if settings_obj else 'auto'
|
||||||
|
|
||||||
|
if mode == "auto":
|
||||||
|
weights = _compute_auto_weights(download_dir)
|
||||||
|
weights["mode"] = "auto"
|
||||||
|
return weights
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"mode": "manual",
|
||||||
|
"anime_weight": getattr(settings_obj, 'content_weight_anime', 2),
|
||||||
|
"series_weight": getattr(settings_obj, 'content_weight_series', 1),
|
||||||
|
"anime_count": None,
|
||||||
|
"series_count": None,
|
||||||
|
"total": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
|
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)
|
||||||
+144
-112
@@ -1,18 +1,19 @@
|
|||||||
"""Sonarr webhook handler and integration logic"""
|
"""Sonarr webhook handler and integration logic using SQLModel"""
|
||||||
import hmac
|
import hmac
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
from typing import Optional, Dict, List, Any
|
||||||
from typing import Optional, Dict, List, Tuple, Any
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from app.database import engine
|
||||||
from app.models.sonarr import (
|
from app.models.sonarr import (
|
||||||
SonarrWebhookPayload,
|
SonarrWebhookPayload,
|
||||||
SonarrEventType,
|
SonarrEventType,
|
||||||
SonarrMapping,
|
SonarrMapping,
|
||||||
|
SonarrMappingTable,
|
||||||
SonarrConfig,
|
SonarrConfig,
|
||||||
|
SonarrConfigTable,
|
||||||
SonarrDownloadRequest
|
SonarrDownloadRequest
|
||||||
)
|
)
|
||||||
from app.models import DownloadRequest
|
from app.models import DownloadRequest
|
||||||
@@ -23,69 +24,150 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class SonarrHandler:
|
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"):
|
def __init__(self, config_path: str = None, mappings_path: str = None):
|
||||||
self.config_path = Path(config_path)
|
|
||||||
self.mappings_path = Path(mappings_path)
|
|
||||||
self.config = self._load_config()
|
|
||||||
self.mappings = self._load_mappings()
|
|
||||||
self.download_manager = None
|
self.download_manager = None
|
||||||
|
self._ensure_default_config()
|
||||||
# Create config directories if they don't exist
|
|
||||||
self.config_path.parent.mkdir(exist_ok=True)
|
|
||||||
self.mappings_path.parent.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
def set_download_manager(self, download_manager):
|
def set_download_manager(self, download_manager):
|
||||||
self.download_manager = download_manager
|
self.download_manager = download_manager
|
||||||
|
|
||||||
def _load_config(self) -> SonarrConfig:
|
def _ensure_default_config(self):
|
||||||
"""Load Sonarr configuration from file"""
|
"""Ensure a default config exists in the database"""
|
||||||
if self.config_path.exists():
|
with Session(engine) as session:
|
||||||
try:
|
statement = select(SonarrConfigTable)
|
||||||
with open(self.config_path, 'r') as f:
|
if not session.exec(statement).first():
|
||||||
data = json.load(f)
|
session.add(SonarrConfigTable())
|
||||||
return SonarrConfig(**data)
|
session.commit()
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to load Sonarr config: {e}")
|
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()
|
return SonarrConfig()
|
||||||
|
|
||||||
def _save_config(self):
|
def update_config(self, config: SonarrConfig) -> SonarrConfig:
|
||||||
try:
|
"""Update configuration"""
|
||||||
temp_file = f"{self.config_path}.tmp"
|
with Session(engine) as session:
|
||||||
with open(temp_file, 'w') as f:
|
statement = select(SonarrConfigTable)
|
||||||
json.dump(self.config.model_dump(mode='json'), f, indent=2)
|
db_config = session.exec(statement).first()
|
||||||
os.replace(temp_file, self.config_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to save Sonarr config: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _load_mappings(self) -> List[SonarrMapping]:
|
if not db_config:
|
||||||
"""Load Sonarr to anime mappings from file"""
|
db_config = SonarrConfigTable()
|
||||||
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 _save_mappings(self):
|
db_config.webhook_enabled = config.webhook_enabled
|
||||||
try:
|
db_config.webhook_secret = config.webhook_secret
|
||||||
os.makedirs(os.path.dirname(self.mappings_path), exist_ok=True)
|
db_config.auto_download_enabled = config.auto_download_enabled
|
||||||
temp_file = f"{self.mappings_path}.tmp"
|
db_config.default_language = config.default_language
|
||||||
with open(temp_file, 'w') as f:
|
db_config.default_quality = config.default_quality
|
||||||
mappings_data = [m.model_dump(mode='json') for m in self.mappings]
|
db_config.default_provider = config.default_provider
|
||||||
json.dump(mappings_data, f, indent=2)
|
db_config.verify_hmac = config.verify_hmac
|
||||||
os.replace(temp_file, self.mappings_path)
|
db_config.log_webhooks = config.log_webhooks
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to save mappings: {e}")
|
session.add(db_config)
|
||||||
raise
|
session.commit()
|
||||||
|
|
||||||
|
logger.info("Sonarr configuration updated in database")
|
||||||
|
return config
|
||||||
|
|
||||||
|
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:
|
def verify_hmac(self, payload: bytes, signature: str) -> bool:
|
||||||
"""Verify HMAC SHA256 signature"""
|
"""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
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -94,7 +176,7 @@ class SonarrHandler:
|
|||||||
signature = signature[7:]
|
signature = signature[7:]
|
||||||
|
|
||||||
computed_hmac = hmac.new(
|
computed_hmac = hmac.new(
|
||||||
self.config.webhook_secret.encode(),
|
config.webhook_secret.encode(),
|
||||||
payload,
|
payload,
|
||||||
hashlib.sha256
|
hashlib.sha256
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
@@ -104,57 +186,6 @@ class SonarrHandler:
|
|||||||
logger.error(f"HMAC verification failed: {e}")
|
logger.error(f"HMAC verification failed: {e}")
|
||||||
return False
|
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]:
|
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"""
|
"""Search for anime by title using specified provider"""
|
||||||
try:
|
try:
|
||||||
@@ -197,15 +228,16 @@ class SonarrHandler:
|
|||||||
|
|
||||||
async def process_webhook(self, payload: SonarrWebhookPayload) -> Dict[str, Any]:
|
async def process_webhook(self, payload: SonarrWebhookPayload) -> Dict[str, Any]:
|
||||||
"""Process Sonarr webhook payload"""
|
"""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"}
|
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}")
|
logger.info(f"Received Sonarr webhook: {payload.eventType.value}")
|
||||||
|
|
||||||
# Handle different event types
|
# Handle different event types
|
||||||
if payload.eventType == SonarrEventType.GRAB:
|
if payload.eventType == SonarrEventType.GRAB:
|
||||||
return await self._handle_grab(payload)
|
return await self._handle_grab(payload, config)
|
||||||
elif payload.eventType == SonarrEventType.DOWNLOAD:
|
elif payload.eventType == SonarrEventType.DOWNLOAD:
|
||||||
return await self._handle_download(payload)
|
return await self._handle_download(payload)
|
||||||
elif payload.eventType == SonarrEventType.RENAME:
|
elif payload.eventType == SonarrEventType.RENAME:
|
||||||
@@ -217,9 +249,9 @@ class SonarrHandler:
|
|||||||
else:
|
else:
|
||||||
return {"status": "ignored", "reason": f"Unhandled event type: {payload.eventType}"}
|
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)"""
|
"""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"}
|
return {"status": "ignored", "reason": "Auto-download disabled"}
|
||||||
|
|
||||||
if not payload.series or not payload.episodes:
|
if not payload.series or not payload.episodes:
|
||||||
|
|||||||
+107
@@ -2,12 +2,119 @@
|
|||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
||||||
|
# Verify it's actually the right site, not a parking/placeholder page
|
||||||
|
content = response.text.lower()
|
||||||
|
body_size = len(response.text)
|
||||||
|
# Valid pages should be reasonably large and contain expected keywords
|
||||||
|
if body_size > 10000 and ('french' in content or 'stream' in content or 'serie' in content or 'anime' in content or 'film' in content or 'telechargement' in content or 'zone' in content):
|
||||||
|
logger.info(f"Active domain found for {provider_id}: {domain} ({body_size} bytes)")
|
||||||
|
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:
|
def sanitize_filename(filename: str, max_length: int = 255) -> str:
|
||||||
"""
|
"""
|
||||||
Safely sanitize filenames to prevent path traversal and invalid characters
|
Safely sanitize filenames to prevent path traversal and invalid characters
|
||||||
|
|||||||
+176
-164
@@ -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 json
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
@@ -7,165 +7,163 @@ from datetime import datetime, timedelta
|
|||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from app.database import engine
|
||||||
from app.models.watchlist import (
|
from app.models.watchlist import (
|
||||||
WatchlistItem,
|
WatchlistItem,
|
||||||
|
WatchlistItemTable,
|
||||||
WatchlistItemCreate,
|
WatchlistItemCreate,
|
||||||
WatchlistItemUpdate,
|
WatchlistItemUpdate,
|
||||||
WatchlistStatus,
|
WatchlistStatus,
|
||||||
WatchlistSettings,
|
WatchlistSettings,
|
||||||
|
WatchlistSettingsTable,
|
||||||
NewEpisodeInfo,
|
NewEpisodeInfo,
|
||||||
AutoDownloadResult
|
AutoDownloadResult
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Watchlist database file
|
|
||||||
WATCHLIST_DB_FILE = "config/watchlist.json"
|
|
||||||
WATCHLIST_SETTINGS_FILE = "config/watchlist_settings.json"
|
|
||||||
|
|
||||||
|
|
||||||
class WatchlistManager:
|
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):
|
def __init__(self):
|
||||||
self.db_file = db_file
|
|
||||||
self.settings_file = WATCHLIST_SETTINGS_FILE
|
|
||||||
self.watchlist: Dict[str, WatchlistItem] = {}
|
|
||||||
self.settings: Optional[WatchlistSettings] = None
|
self.settings: Optional[WatchlistSettings] = None
|
||||||
self._load_watchlist()
|
|
||||||
self._load_settings()
|
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):
|
def _load_settings(self):
|
||||||
"""Load watchlist settings from JSON file"""
|
"""Load watchlist settings from database"""
|
||||||
try:
|
try:
|
||||||
if os.path.exists(self.settings_file):
|
with Session(engine) as session:
|
||||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
statement = select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == "default")
|
||||||
data = json.load(f)
|
db_settings = session.exec(statement).first()
|
||||||
self.settings = WatchlistSettings(**data)
|
if db_settings:
|
||||||
logger.info(f"Loaded watchlist 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:
|
else:
|
||||||
self.settings = WatchlistSettings()
|
self.settings = WatchlistSettings()
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
logger.info("Settings file not found, using defaults")
|
logger.info("Settings not found in database, created defaults")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading settings: {e}")
|
logger.error(f"Error loading settings from database: {e}")
|
||||||
self.settings = WatchlistSettings()
|
self.settings = WatchlistSettings()
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True)
|
with Session(engine) as session:
|
||||||
temp_file = f"{self.settings_file}.tmp"
|
statement = select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == "default")
|
||||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
db_settings = session.exec(statement).first()
|
||||||
json.dump(self.settings.model_dump(mode='json'), f, indent=2, ensure_ascii=False)
|
|
||||||
os.replace(temp_file, self.settings_file)
|
if db_settings:
|
||||||
logger.debug("Saved watchlist 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:
|
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]:
|
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"""
|
"""Get all watchlist items, optionally filtered by user and status"""
|
||||||
items = list(self.watchlist.values())
|
with Session(engine) as session:
|
||||||
|
statement = select(WatchlistItemTable)
|
||||||
if user_id:
|
if user_id:
|
||||||
items = [item for item in items if item.user_id == user_id]
|
statement = statement.where(WatchlistItemTable.user_id == user_id)
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
items = [item for item in items if item.status == status]
|
statement = statement.where(WatchlistItemTable.status == status)
|
||||||
|
|
||||||
# Sort by added_at descending
|
# Sort by added_at descending
|
||||||
items.sort(key=lambda x: x.added_at, reverse=True)
|
statement = statement.order_by(WatchlistItemTable.added_at.desc())
|
||||||
return items
|
|
||||||
|
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]:
|
def get_by_id(self, item_id: str) -> Optional[WatchlistItem]:
|
||||||
"""Get a watchlist item by ID"""
|
"""Get a specific watchlist item by ID"""
|
||||||
return self.watchlist.get(item_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]:
|
def get_by_anime_url(self, anime_url: str, user_id: str) -> Optional[WatchlistItem]:
|
||||||
"""Get a watchlist item by anime URL and user ID"""
|
"""Get a watchlist item by anime URL and user ID"""
|
||||||
for item in self.watchlist.values():
|
with Session(engine) as session:
|
||||||
if item.anime_url == anime_url and item.user_id == user_id:
|
statement = select(WatchlistItemTable).where(
|
||||||
return item
|
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
|
return None
|
||||||
|
|
||||||
def create(self, user_id: str, item_data: WatchlistItemCreate) -> WatchlistItem:
|
def add(self, user_id: str, item_create: WatchlistItemCreate) -> WatchlistItem:
|
||||||
"""Create a new watchlist item"""
|
"""Add a new anime to the watchlist"""
|
||||||
# Check if already exists
|
# Check if already in watchlist for this user
|
||||||
existing = self.get_by_anime_url(item_data.anime_url, user_id)
|
existing = self.get_by_anime_url(item_create.anime_url, user_id)
|
||||||
if existing:
|
if existing:
|
||||||
raise ValueError(f"Anime already in watchlist (ID: {existing.id})")
|
return existing
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
# Create new item
|
# Create new item
|
||||||
item_id = str(uuid.uuid4())
|
db_item = WatchlistItemTable(
|
||||||
now = datetime.now()
|
|
||||||
|
|
||||||
watchlist_item = WatchlistItem(
|
|
||||||
id=item_id,
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
anime_title=item_data.anime_title,
|
anime_title=item_create.anime_title,
|
||||||
anime_url=item_data.anime_url,
|
anime_url=item_create.anime_url,
|
||||||
provider_id=item_data.provider_id,
|
provider_id=item_create.provider_id,
|
||||||
lang=item_data.lang,
|
lang=item_create.lang,
|
||||||
auto_download=item_data.auto_download,
|
auto_download=item_create.auto_download,
|
||||||
quality_preference=item_data.quality_preference,
|
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,
|
status=WatchlistStatus.ACTIVE,
|
||||||
poster_image=item_data.poster_image,
|
added_at=datetime.now(),
|
||||||
cover_image=item_data.cover_image,
|
updated_at=datetime.now(),
|
||||||
synopsis=item_data.synopsis,
|
last_episode_downloaded=0
|
||||||
genres=item_data.genres,
|
|
||||||
added_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
last_checked=None,
|
|
||||||
last_episode_downloaded=0,
|
|
||||||
total_episodes=None
|
|
||||||
)
|
)
|
||||||
|
db_item.genres = item_create.genres
|
||||||
|
|
||||||
self.watchlist[item_id] = watchlist_item
|
session.add(db_item)
|
||||||
self._save_watchlist()
|
session.commit()
|
||||||
logger.info(f"Added anime to watchlist: {watchlist_item.anime_title} (ID: {item_id})")
|
session.refresh(db_item)
|
||||||
return watchlist_item
|
|
||||||
|
logger.info(f"Added {db_item.anime_title} to watchlist for user {user_id}")
|
||||||
|
return self._to_api_model(db_item)
|
||||||
|
|
||||||
|
# Alias for backward compatibility if needed
|
||||||
|
add_item = add
|
||||||
|
|
||||||
def update(self, item_id: str, update_data) -> Optional[WatchlistItem]:
|
def update(self, item_id: str, update_data) -> Optional[WatchlistItem]:
|
||||||
"""Update a watchlist item
|
"""Update a watchlist item"""
|
||||||
|
with Session(engine) as session:
|
||||||
Args:
|
db_item = session.get(WatchlistItemTable, item_id)
|
||||||
item_id: Item ID to update
|
if not db_item:
|
||||||
update_data: WatchlistItemUpdate object or dict with fields to update
|
|
||||||
"""
|
|
||||||
item = self.watchlist.get(item_id)
|
|
||||||
if not item:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Handle both dict and WatchlistItemUpdate
|
# Handle both dict and WatchlistItemUpdate
|
||||||
@@ -174,80 +172,94 @@ class WatchlistManager:
|
|||||||
else:
|
else:
|
||||||
update_dict = update_data.model_dump(exclude_unset=True)
|
update_dict = update_data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Update fields
|
for key, value in update_dict.items():
|
||||||
for field, value in update_dict.items():
|
if hasattr(db_item, key):
|
||||||
if value is not None:
|
setattr(db_item, key, value)
|
||||||
setattr(item, field, value)
|
|
||||||
|
db_item.updated_at = datetime.now()
|
||||||
|
|
||||||
|
session.add(db_item)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_item)
|
||||||
|
|
||||||
item.updated_at = datetime.now()
|
|
||||||
self._save_watchlist()
|
|
||||||
logger.info(f"Updated watchlist item: {item_id}")
|
logger.info(f"Updated watchlist item: {item_id}")
|
||||||
return item
|
return self._to_api_model(db_item)
|
||||||
|
|
||||||
|
# Alias for backward compatibility
|
||||||
|
update_item = update
|
||||||
|
|
||||||
def delete(self, item_id: str) -> bool:
|
def delete(self, item_id: str) -> bool:
|
||||||
"""Delete a watchlist item"""
|
"""Remove an item from the watchlist"""
|
||||||
if item_id in self.watchlist:
|
with Session(engine) as session:
|
||||||
del self.watchlist[item_id]
|
db_item = session.get(WatchlistItemTable, item_id)
|
||||||
self._save_watchlist()
|
if not db_item:
|
||||||
logger.info(f"Deleted watchlist item: {item_id}")
|
|
||||||
return True
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_check_time(self, item_id: str, last_episode: int) -> Optional[WatchlistItem]:
|
session.delete(db_item)
|
||||||
"""Update last_checked time and last_episode_downloaded"""
|
session.commit()
|
||||||
item = self.watchlist.get(item_id)
|
|
||||||
if not item:
|
|
||||||
return None
|
|
||||||
|
|
||||||
item.last_checked = datetime.now()
|
logger.info(f"Deleted item {item_id} from watchlist")
|
||||||
item.last_episode_downloaded = max(item.last_episode_downloaded, last_episode)
|
return True
|
||||||
item.updated_at = datetime.now()
|
|
||||||
self._save_watchlist()
|
def update_last_checked(self, item_id: str, last_episode: Optional[int] = None):
|
||||||
return item
|
"""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()
|
||||||
|
|
||||||
|
# Alias for backward compatibility
|
||||||
|
update_check_time = update_last_checked
|
||||||
|
|
||||||
|
def get_due_items(self) -> List[WatchlistItem]:
|
||||||
|
"""Get all items that are due for a check based on current settings"""
|
||||||
|
return self.get_due_for_check(self.settings.check_interval_hours if self.settings else 6)
|
||||||
|
|
||||||
|
def get_due_for_check(self, interval_hours: int) -> List[WatchlistItem]:
|
||||||
|
"""Get all items that are due for a check based on settings"""
|
||||||
|
interval = timedelta(hours=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 get_settings(self) -> WatchlistSettings:
|
def get_settings(self) -> WatchlistSettings:
|
||||||
"""Get watchlist settings"""
|
"""Get global watchlist settings"""
|
||||||
if not self.settings:
|
if self.settings is None:
|
||||||
self.settings = WatchlistSettings()
|
self._load_settings()
|
||||||
return self.settings
|
return self.settings
|
||||||
|
|
||||||
def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
|
def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
|
||||||
"""Update watchlist settings"""
|
"""Update global watchlist settings"""
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
logger.info("Updated watchlist settings")
|
logger.info("Updated watchlist settings")
|
||||||
return self.settings
|
return self.settings
|
||||||
|
|
||||||
def get_due_for_check(self, check_interval_hours: Optional[int] = None) -> List[WatchlistItem]:
|
def get_stats(self, user_id: str) -> Dict:
|
||||||
"""Get items that are due for checking"""
|
"""Get statistics for a user's watchlist"""
|
||||||
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"""
|
|
||||||
items = self.get_all(user_id=user_id)
|
items = self.get_all(user_id=user_id)
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"total": len(items),
|
"total_items": len(items),
|
||||||
"active": len([i for i in items if i.status == WatchlistStatus.ACTIVE]),
|
"active_items": len([i for i in items if i.status == WatchlistStatus.ACTIVE]),
|
||||||
"paused": len([i for i in items if i.status == WatchlistStatus.PAUSED]),
|
"paused_items": len([i for i in items if i.status == WatchlistStatus.PAUSED]),
|
||||||
"completed": len([i for i in items if i.status == WatchlistStatus.COMPLETED]),
|
"completed_items": 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_episodes_downloaded": sum(i.last_episode_downloaded for i in items),
|
||||||
"providers": {}
|
"providers": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
{
|
||||||
|
"ENN3p91PrQd0kyV3C_n4l6sGthyZgmDM77ua-VibJKU": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "ENN3p91PrQd0kyV3C_n4l6sGthyZgmDM77ua-VibJKU",
|
||||||
|
"created_at": "2026-03-06T22:01:01.865697",
|
||||||
|
"expires_at": "2026-04-05T22:01:01.865619"
|
||||||
|
},
|
||||||
|
"vJGtD3bvFMXp6WRH7sBdQF7BlV9ioy2Ah8OcjiBtQTs": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "vJGtD3bvFMXp6WRH7sBdQF7BlV9ioy2Ah8OcjiBtQTs",
|
||||||
|
"created_at": "2026-03-06T22:03:55.154118",
|
||||||
|
"expires_at": "2026-04-05T22:03:55.154019"
|
||||||
|
},
|
||||||
|
"fOLNOTkf4nhC25NgLBNKutmaLqlZkg9wuXaP320mE-o": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "fOLNOTkf4nhC25NgLBNKutmaLqlZkg9wuXaP320mE-o",
|
||||||
|
"created_at": "2026-03-06T22:06:48.751392",
|
||||||
|
"expires_at": "2026-04-05T22:06:48.751237"
|
||||||
|
},
|
||||||
|
"OYOLFjWqvNMFKAOIfuoEjSrLIKQ8MuuZckGs3Zib0WU": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "OYOLFjWqvNMFKAOIfuoEjSrLIKQ8MuuZckGs3Zib0WU",
|
||||||
|
"created_at": "2026-03-06T22:06:48.753454",
|
||||||
|
"expires_at": "2026-04-05T22:06:48.753349"
|
||||||
|
},
|
||||||
|
"pxx-nUMwbKF3fIYtdRKLd4kemUkfOfDyh5IByuOY4iY": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "pxx-nUMwbKF3fIYtdRKLd4kemUkfOfDyh5IByuOY4iY",
|
||||||
|
"created_at": "2026-03-06T22:06:48.756403",
|
||||||
|
"expires_at": "2026-04-05T22:06:48.756301"
|
||||||
|
},
|
||||||
|
"-lGSkRZ8uCFRTqvL9GRP7Fn1BaG7Wju3zXFKB3nu75o": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "-lGSkRZ8uCFRTqvL9GRP7Fn1BaG7Wju3zXFKB3nu75o",
|
||||||
|
"created_at": "2026-03-06T22:06:48.757822",
|
||||||
|
"expires_at": "2026-04-05T22:06:48.757728"
|
||||||
|
},
|
||||||
|
"x5is2S9FWTftUjmbwfuQpp3dL9Svxb7I9n0hmf1Gp0g": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "x5is2S9FWTftUjmbwfuQpp3dL9Svxb7I9n0hmf1Gp0g",
|
||||||
|
"created_at": "2026-03-06T22:06:48.759219",
|
||||||
|
"expires_at": "2026-04-05T22:06:48.759121"
|
||||||
|
},
|
||||||
|
"E5l5iq2i-PY5eBsh4KUMbvZnqTThpnzZacp0jJPSkDw": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "E5l5iq2i-PY5eBsh4KUMbvZnqTThpnzZacp0jJPSkDw",
|
||||||
|
"created_at": "2026-03-06T22:07:03.414591",
|
||||||
|
"expires_at": "2026-04-05T22:07:03.414466"
|
||||||
|
},
|
||||||
|
"XfiKphKpqwI-nIaEY_kIA66esSkGHWRCxH-RQPr3jG8": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "XfiKphKpqwI-nIaEY_kIA66esSkGHWRCxH-RQPr3jG8",
|
||||||
|
"created_at": "2026-03-06T22:07:27.981118",
|
||||||
|
"expires_at": "2026-04-05T22:07:27.980974"
|
||||||
|
},
|
||||||
|
"YO1zHyOWn2xSzyT0QqiQRuXmlKz8QNIaxncndrYtPWQ": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "YO1zHyOWn2xSzyT0QqiQRuXmlKz8QNIaxncndrYtPWQ",
|
||||||
|
"created_at": "2026-03-06T22:07:27.982903",
|
||||||
|
"expires_at": "2026-04-05T22:07:27.982803"
|
||||||
|
},
|
||||||
|
"OBNRf9sSVBfJh2317mfHwsVeBwoIONWUafcFL6w-5Ek": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "OBNRf9sSVBfJh2317mfHwsVeBwoIONWUafcFL6w-5Ek",
|
||||||
|
"created_at": "2026-03-06T22:07:27.985521",
|
||||||
|
"expires_at": "2026-04-05T22:07:27.985410"
|
||||||
|
},
|
||||||
|
"9Tt2x8y4Gu2GekLuSs8Q_oWsYr1XvmD5zZIA9O1aE3s": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "9Tt2x8y4Gu2GekLuSs8Q_oWsYr1XvmD5zZIA9O1aE3s",
|
||||||
|
"created_at": "2026-03-06T22:07:27.986984",
|
||||||
|
"expires_at": "2026-04-05T22:07:27.986883"
|
||||||
|
},
|
||||||
|
"vQGdyZKZTmGU0CsxE60KjlErK8WXpoD33rGqupeQ8MI": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "vQGdyZKZTmGU0CsxE60KjlErK8WXpoD33rGqupeQ8MI",
|
||||||
|
"created_at": "2026-03-06T22:07:27.988625",
|
||||||
|
"expires_at": "2026-04-05T22:07:27.988525"
|
||||||
|
},
|
||||||
|
"qSfC9YNqMDfd9-EyYsaFuwzOjRLiqUy_7Lvk9_KuIqM": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "qSfC9YNqMDfd9-EyYsaFuwzOjRLiqUy_7Lvk9_KuIqM",
|
||||||
|
"created_at": "2026-03-06T22:07:33.163399",
|
||||||
|
"expires_at": "2026-04-05T22:07:33.163230"
|
||||||
|
},
|
||||||
|
"8wLZuq1D4_XtCfUk_zET95vT-wMyf6sG4fuMv8Mfke8": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "8wLZuq1D4_XtCfUk_zET95vT-wMyf6sG4fuMv8Mfke8",
|
||||||
|
"created_at": "2026-03-06T22:07:33.165736",
|
||||||
|
"expires_at": "2026-04-05T22:07:33.165608"
|
||||||
|
},
|
||||||
|
"jEQy3kLp6POI_R-CucHx2Vtb02LofZRHqqdaK779iGE": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "jEQy3kLp6POI_R-CucHx2Vtb02LofZRHqqdaK779iGE",
|
||||||
|
"created_at": "2026-03-06T22:07:33.168776",
|
||||||
|
"expires_at": "2026-04-05T22:07:33.168669"
|
||||||
|
},
|
||||||
|
"XZxP53qg7UXYpBv7jilxrrjMzCsKeutcz26y5JdgyZA": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "XZxP53qg7UXYpBv7jilxrrjMzCsKeutcz26y5JdgyZA",
|
||||||
|
"created_at": "2026-03-06T22:07:33.170429",
|
||||||
|
"expires_at": "2026-04-05T22:07:33.170321"
|
||||||
|
},
|
||||||
|
"Bw-7nTNKf08sDNk2kvyNVUihAXPRK2Nx9dEpI-Ih1Og": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "Bw-7nTNKf08sDNk2kvyNVUihAXPRK2Nx9dEpI-Ih1Og",
|
||||||
|
"created_at": "2026-03-06T22:07:33.172080",
|
||||||
|
"expires_at": "2026-04-05T22:07:33.171974"
|
||||||
|
},
|
||||||
|
"N_zKMbptJrms8_X14cmqkqf-zfZZnh2x7geNgqxvWMY": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "N_zKMbptJrms8_X14cmqkqf-zfZZnh2x7geNgqxvWMY",
|
||||||
|
"created_at": "2026-03-06T22:08:54.290837",
|
||||||
|
"expires_at": "2026-04-05T22:08:54.290674"
|
||||||
|
},
|
||||||
|
"DgR8zvaP24tLExKaTs2-yAvgc7UKX-eebF5f4Vd2tpQ": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "DgR8zvaP24tLExKaTs2-yAvgc7UKX-eebF5f4Vd2tpQ",
|
||||||
|
"created_at": "2026-03-06T22:08:54.292851",
|
||||||
|
"expires_at": "2026-04-05T22:08:54.292732"
|
||||||
|
},
|
||||||
|
"MbJtb1GcO1BDU10f9L0uhdJtoqJ-TuqVFoGvLAp8Sy4": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "MbJtb1GcO1BDU10f9L0uhdJtoqJ-TuqVFoGvLAp8Sy4",
|
||||||
|
"created_at": "2026-03-06T22:08:54.295788",
|
||||||
|
"expires_at": "2026-04-05T22:08:54.295675"
|
||||||
|
},
|
||||||
|
"3jYUIjL4CawzMUeoGMX4psEOBptQqGFFm7DNIQb_oWM": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "3jYUIjL4CawzMUeoGMX4psEOBptQqGFFm7DNIQb_oWM",
|
||||||
|
"created_at": "2026-03-06T22:08:54.297426",
|
||||||
|
"expires_at": "2026-04-05T22:08:54.297325"
|
||||||
|
},
|
||||||
|
"_hjtqlEx-bIXW7fl9mkRtQYEbDzIImZL31IhtQUPit0": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "_hjtqlEx-bIXW7fl9mkRtQYEbDzIImZL31IhtQUPit0",
|
||||||
|
"created_at": "2026-03-06T22:08:54.299268",
|
||||||
|
"expires_at": "2026-04-05T22:08:54.299159"
|
||||||
|
},
|
||||||
|
"pYGDEjV-snw-Ua9r1Kzu3Eq7t-Kr2VVWjGhBIrJFmj4": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "pYGDEjV-snw-Ua9r1Kzu3Eq7t-Kr2VVWjGhBIrJFmj4",
|
||||||
|
"created_at": "2026-03-06T22:09:24.318148",
|
||||||
|
"expires_at": "2026-04-05T22:09:24.317977"
|
||||||
|
},
|
||||||
|
"3nLTLes3RLl4wuB_EvKHVy37Prusdp334Cev-xroWCc": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "3nLTLes3RLl4wuB_EvKHVy37Prusdp334Cev-xroWCc",
|
||||||
|
"created_at": "2026-03-06T22:09:24.320197",
|
||||||
|
"expires_at": "2026-04-05T22:09:24.320080"
|
||||||
|
},
|
||||||
|
"U-5pZ1bs0mTPQO5EetauFC1Tt9nvksMdy6o7RTAo9v0": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "U-5pZ1bs0mTPQO5EetauFC1Tt9nvksMdy6o7RTAo9v0",
|
||||||
|
"created_at": "2026-03-06T22:09:24.323151",
|
||||||
|
"expires_at": "2026-04-05T22:09:24.323044"
|
||||||
|
},
|
||||||
|
"ZTrkUSbJH8Glt7IIQUddUtAc2Aj4Y2R2h8FIAPEYH70": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "ZTrkUSbJH8Glt7IIQUddUtAc2Aj4Y2R2h8FIAPEYH70",
|
||||||
|
"created_at": "2026-03-06T22:09:24.324867",
|
||||||
|
"expires_at": "2026-04-05T22:09:24.324760"
|
||||||
|
},
|
||||||
|
"NXDOlsQHhIvU2iKAr5Lvd2TFWPtrDwCZRv_qhbkalXU": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "NXDOlsQHhIvU2iKAr5Lvd2TFWPtrDwCZRv_qhbkalXU",
|
||||||
|
"created_at": "2026-03-06T22:09:24.326840",
|
||||||
|
"expires_at": "2026-04-05T22:09:24.326737"
|
||||||
|
},
|
||||||
|
"OtWEFFVAaWeEjhD4oABMjgCEMpgXqVoYQA1FurO0vv4": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "OtWEFFVAaWeEjhD4oABMjgCEMpgXqVoYQA1FurO0vv4",
|
||||||
|
"created_at": "2026-03-06T22:10:26.790594",
|
||||||
|
"expires_at": "2026-04-05T22:10:26.790416"
|
||||||
|
},
|
||||||
|
"1LXmBhsC7ii_9mRA_UoHzXu-tEB-grWJOqZattviJ3I": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "1LXmBhsC7ii_9mRA_UoHzXu-tEB-grWJOqZattviJ3I",
|
||||||
|
"created_at": "2026-03-06T22:10:26.792786",
|
||||||
|
"expires_at": "2026-04-05T22:10:26.792640"
|
||||||
|
},
|
||||||
|
"okiqq-6OzY9oWg1W9-SZgzLatTCpae9MCCp6KZlV10w": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "okiqq-6OzY9oWg1W9-SZgzLatTCpae9MCCp6KZlV10w",
|
||||||
|
"created_at": "2026-03-06T22:10:26.795866",
|
||||||
|
"expires_at": "2026-04-05T22:10:26.795737"
|
||||||
|
},
|
||||||
|
"ugGlPQF6pHzNwWeJtj6T291hTzohtNm4RmgVk8ZVDDE": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "ugGlPQF6pHzNwWeJtj6T291hTzohtNm4RmgVk8ZVDDE",
|
||||||
|
"created_at": "2026-03-06T22:10:26.797631",
|
||||||
|
"expires_at": "2026-04-05T22:10:26.797524"
|
||||||
|
},
|
||||||
|
"CjKhxE2yHPbxqVl8xlHvqOY1JEaMWAMEvuwHZNVlLhE": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "CjKhxE2yHPbxqVl8xlHvqOY1JEaMWAMEvuwHZNVlLhE",
|
||||||
|
"created_at": "2026-03-06T22:10:26.799655",
|
||||||
|
"expires_at": "2026-04-05T22:10:26.799536"
|
||||||
|
},
|
||||||
|
"kUZqeke-HAsST14Wc55f4H7cvgXk6mqZMt8QCImg3OE": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "kUZqeke-HAsST14Wc55f4H7cvgXk6mqZMt8QCImg3OE",
|
||||||
|
"created_at": "2026-03-06T22:27:21.684870",
|
||||||
|
"expires_at": "2026-04-05T22:27:21.684713"
|
||||||
|
},
|
||||||
|
"X4oxSgjaDzKWhSTRlzNJIWwYK02uhTxt7flgk3iviZg": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "X4oxSgjaDzKWhSTRlzNJIWwYK02uhTxt7flgk3iviZg",
|
||||||
|
"created_at": "2026-03-06T22:27:21.686951",
|
||||||
|
"expires_at": "2026-04-05T22:27:21.686838"
|
||||||
|
},
|
||||||
|
"lK62sk0eZGKnlXPPtgl1_3RP2uKiIAUoBhp9qrGsmdM": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "lK62sk0eZGKnlXPPtgl1_3RP2uKiIAUoBhp9qrGsmdM",
|
||||||
|
"created_at": "2026-03-06T22:27:21.689978",
|
||||||
|
"expires_at": "2026-04-05T22:27:21.689871"
|
||||||
|
},
|
||||||
|
"CCQ6UCKyt6yeCBX1YK-e9oQ6TYBlFW_4NE2AlNoDaz4": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "CCQ6UCKyt6yeCBX1YK-e9oQ6TYBlFW_4NE2AlNoDaz4",
|
||||||
|
"created_at": "2026-03-06T22:27:21.694564",
|
||||||
|
"expires_at": "2026-04-05T22:27:21.694451"
|
||||||
|
},
|
||||||
|
"2Rmed4CEavVu78CcBfrtkPP-CIfDrw7FJPWjuoWEMX4": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "2Rmed4CEavVu78CcBfrtkPP-CIfDrw7FJPWjuoWEMX4",
|
||||||
|
"created_at": "2026-03-06T22:27:21.696368",
|
||||||
|
"expires_at": "2026-04-05T22:27:21.696259"
|
||||||
|
},
|
||||||
|
"innQUKWqDDOx87cBrpe_UyttX93T90HPo2AZP3Zcl0w": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "innQUKWqDDOx87cBrpe_UyttX93T90HPo2AZP3Zcl0w",
|
||||||
|
"created_at": "2026-03-06T22:28:22.440825",
|
||||||
|
"expires_at": "2026-04-05T22:28:22.440584"
|
||||||
|
},
|
||||||
|
"FHnmFUIbDHCy-WX1bynRQTaXSQ_T2A8TowTUrBxAvWc": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "FHnmFUIbDHCy-WX1bynRQTaXSQ_T2A8TowTUrBxAvWc",
|
||||||
|
"created_at": "2026-03-06T22:28:22.443279",
|
||||||
|
"expires_at": "2026-04-05T22:28:22.443148"
|
||||||
|
},
|
||||||
|
"xSe9XubjuLbcyJfdIXIFZpUO67c33DSMd452YXqlfLc": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "xSe9XubjuLbcyJfdIXIFZpUO67c33DSMd452YXqlfLc",
|
||||||
|
"created_at": "2026-03-06T22:28:22.446772",
|
||||||
|
"expires_at": "2026-04-05T22:28:22.446637"
|
||||||
|
},
|
||||||
|
"Ne1YW4IV1JXRde4OCuadCQORF40TSBR4cHWIWfrTXCI": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "Ne1YW4IV1JXRde4OCuadCQORF40TSBR4cHWIWfrTXCI",
|
||||||
|
"created_at": "2026-03-06T22:28:22.448831",
|
||||||
|
"expires_at": "2026-04-05T22:28:22.448710"
|
||||||
|
},
|
||||||
|
"cidw7ZAy2g1NmA6_1ynKKcwNH4nwMaH4MQr83JBfc4U": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "cidw7ZAy2g1NmA6_1ynKKcwNH4nwMaH4MQr83JBfc4U",
|
||||||
|
"created_at": "2026-03-06T22:28:22.450873",
|
||||||
|
"expires_at": "2026-04-05T22:28:22.450755"
|
||||||
|
},
|
||||||
|
"oyYCn0jtWSdBk7LAVms-SruvHH7Hn1UYV80OGdvLKlE": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "oyYCn0jtWSdBk7LAVms-SruvHH7Hn1UYV80OGdvLKlE",
|
||||||
|
"created_at": "2026-03-06T22:43:41.536641",
|
||||||
|
"expires_at": "2026-04-05T22:43:41.536473"
|
||||||
|
},
|
||||||
|
"8IeInsR7vpXuRXsttGMErW8UN3mAJPSQqzgSxwtTUPw": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "8IeInsR7vpXuRXsttGMErW8UN3mAJPSQqzgSxwtTUPw",
|
||||||
|
"created_at": "2026-03-06T22:43:41.538970",
|
||||||
|
"expires_at": "2026-04-05T22:43:41.538842"
|
||||||
|
},
|
||||||
|
"9C6TOeSQrdXcjNgmyqqLs1Mwqv5kTnEHDWnwYzMZYOk": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "9C6TOeSQrdXcjNgmyqqLs1Mwqv5kTnEHDWnwYzMZYOk",
|
||||||
|
"created_at": "2026-03-06T22:43:41.542159",
|
||||||
|
"expires_at": "2026-04-05T22:43:41.542042"
|
||||||
|
},
|
||||||
|
"-DLO4uKd0tLii_n7Q1xcwjJlx95eH4Msv09-N-PBcqU": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "-DLO4uKd0tLii_n7Q1xcwjJlx95eH4Msv09-N-PBcqU",
|
||||||
|
"created_at": "2026-03-06T22:43:41.544148",
|
||||||
|
"expires_at": "2026-04-05T22:43:41.544030"
|
||||||
|
},
|
||||||
|
"L4vF52USYYFI530NPFLjRKS6p3t8PQDuuQSMCUZKQpY": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "L4vF52USYYFI530NPFLjRKS6p3t8PQDuuQSMCUZKQpY",
|
||||||
|
"created_at": "2026-03-06T22:43:41.546116",
|
||||||
|
"expires_at": "2026-04-05T22:43:41.545999"
|
||||||
|
},
|
||||||
|
"Ht2tY4F0bAEmqfx3zyhyvl0iE_AR3RSgaFU9t4nWju0": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "Ht2tY4F0bAEmqfx3zyhyvl0iE_AR3RSgaFU9t4nWju0",
|
||||||
|
"created_at": "2026-03-23T15:14:58.571086",
|
||||||
|
"expires_at": "2026-04-22T15:14:58.570921"
|
||||||
|
},
|
||||||
|
"glwLjrtkAQpeayq4I3BXPhelUhHDx9q1XsfEdIRp3ww": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "glwLjrtkAQpeayq4I3BXPhelUhHDx9q1XsfEdIRp3ww",
|
||||||
|
"created_at": "2026-03-23T15:14:58.573282",
|
||||||
|
"expires_at": "2026-04-22T15:14:58.573168"
|
||||||
|
},
|
||||||
|
"3Zm0f39QvivZ-jjik2c6K66ohbFAnNkt0bV_H_2-mTA": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "3Zm0f39QvivZ-jjik2c6K66ohbFAnNkt0bV_H_2-mTA",
|
||||||
|
"created_at": "2026-03-23T15:14:58.576669",
|
||||||
|
"expires_at": "2026-04-22T15:14:58.576537"
|
||||||
|
},
|
||||||
|
"Ek6emHS0OZLGzbl_BiTAvXPZzVimluCRI1NtENHNkOg": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "Ek6emHS0OZLGzbl_BiTAvXPZzVimluCRI1NtENHNkOg",
|
||||||
|
"created_at": "2026-03-23T15:14:58.578685",
|
||||||
|
"expires_at": "2026-04-22T15:14:58.578562"
|
||||||
|
},
|
||||||
|
"8we5TODV6hCiVnVb-8YPDjN5gzqBnrH9SNWRS6fe9tY": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "8we5TODV6hCiVnVb-8YPDjN5gzqBnrH9SNWRS6fe9tY",
|
||||||
|
"created_at": "2026-03-23T15:14:58.580654",
|
||||||
|
"expires_at": "2026-04-22T15:14:58.580531"
|
||||||
|
},
|
||||||
|
"Fj8iMb8t120mE9Ja7vmq2wUPxzJcFuml-Z7iCLhSFW8": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "Fj8iMb8t120mE9Ja7vmq2wUPxzJcFuml-Z7iCLhSFW8",
|
||||||
|
"created_at": "2026-03-23T15:34:35.684297",
|
||||||
|
"expires_at": "2026-04-22T15:34:35.684116"
|
||||||
|
},
|
||||||
|
"BoqiM9cAlxbSasUb95Mbd-nyysLOz0bfW3Wb1nzetkQ": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "BoqiM9cAlxbSasUb95Mbd-nyysLOz0bfW3Wb1nzetkQ",
|
||||||
|
"created_at": "2026-03-23T15:34:35.686743",
|
||||||
|
"expires_at": "2026-04-22T15:34:35.686606"
|
||||||
|
},
|
||||||
|
"H1z5Kul8c1jNt--CVizet8E_0IvSWb71p2re9wqwFdU": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "H1z5Kul8c1jNt--CVizet8E_0IvSWb71p2re9wqwFdU",
|
||||||
|
"created_at": "2026-03-23T15:34:35.690100",
|
||||||
|
"expires_at": "2026-04-22T15:34:35.689977"
|
||||||
|
},
|
||||||
|
"9RjhuJYCWA1hNMljUJTv394N6PQa1MQNH9zKlRHdOYM": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "9RjhuJYCWA1hNMljUJTv394N6PQa1MQNH9zKlRHdOYM",
|
||||||
|
"created_at": "2026-03-23T15:34:35.692293",
|
||||||
|
"expires_at": "2026-04-22T15:34:35.692176"
|
||||||
|
},
|
||||||
|
"BoqqWFQyUyghoo0D5c0TGFePWIWPW-Lc-iZ78c8K0TI": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "BoqqWFQyUyghoo0D5c0TGFePWIWPW-Lc-iZ78c8K0TI",
|
||||||
|
"created_at": "2026-03-23T15:34:35.694464",
|
||||||
|
"expires_at": "2026-04-22T15:34:35.694325"
|
||||||
|
},
|
||||||
|
"wbvoPHiM4uu_Zk8nmuCFKwB_aMbuQYGHpXKl_NqQ-34": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "wbvoPHiM4uu_Zk8nmuCFKwB_aMbuQYGHpXKl_NqQ-34",
|
||||||
|
"created_at": "2026-03-23T16:15:23.555117",
|
||||||
|
"expires_at": "2026-04-22T16:15:23.554918"
|
||||||
|
},
|
||||||
|
"sIjxlWmWy2g0ub9Q7lfV7jD891sLgfK6nl4lVo4IMf0": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "sIjxlWmWy2g0ub9Q7lfV7jD891sLgfK6nl4lVo4IMf0",
|
||||||
|
"created_at": "2026-03-23T16:15:23.557727",
|
||||||
|
"expires_at": "2026-04-22T16:15:23.557585"
|
||||||
|
},
|
||||||
|
"ywtFuhe0qTnVLbHEFJmHVKD7VX2_Xx9ylhBbaYy7w0s": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "ywtFuhe0qTnVLbHEFJmHVKD7VX2_Xx9ylhBbaYy7w0s",
|
||||||
|
"created_at": "2026-03-23T16:15:23.561170",
|
||||||
|
"expires_at": "2026-04-22T16:15:23.561048"
|
||||||
|
},
|
||||||
|
"3r07INGsvk6x1qP1HcUjEeo0Kw2j22mr53VK75mgZJc": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "3r07INGsvk6x1qP1HcUjEeo0Kw2j22mr53VK75mgZJc",
|
||||||
|
"created_at": "2026-03-23T16:15:23.563391",
|
||||||
|
"expires_at": "2026-04-22T16:15:23.563269"
|
||||||
|
},
|
||||||
|
"-uq98ObS1V5yISkuFKGCkt93yc2_4PbfFsbcZlJ_TcE": {
|
||||||
|
"username": "testuser",
|
||||||
|
"token_id": "-uq98ObS1V5yISkuFKGCkt93yc2_4PbfFsbcZlJ_TcE",
|
||||||
|
"created_at": "2026-03-23T16:15:23.565588",
|
||||||
|
"expires_at": "2026-04-22T16:15:23.565458"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
"check_interval_hours": 12,
|
||||||
"auto_download_enabled": true,
|
"auto_download_enabled": false,
|
||||||
"max_concurrent_auto_downloads": 2,
|
"max_concurrent_auto_downloads": 2,
|
||||||
"notify_on_new_episodes": false,
|
"notify_on_new_episodes": false,
|
||||||
"include_completed_anime": false
|
"include_completed_anime": false
|
||||||
|
|||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
const BASE = 'http://127.0.0.1:3000';
|
||||||
|
const opts = { waitUntil: 'domcontentloaded', timeout: 15000 };
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||||
|
|
||||||
|
// Obtenir un token via API
|
||||||
|
const apiCtx = await browser.newContext();
|
||||||
|
const apiPage = await apiCtx.newPage();
|
||||||
|
await apiPage.goto(BASE + '/api/auth/login', opts);
|
||||||
|
const token = await apiPage.evaluate(async () => {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'roman', password: 'roman123' })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return data.access_token || null;
|
||||||
|
});
|
||||||
|
await apiCtx.close();
|
||||||
|
console.log(`Token obtained: ${token ? token.substring(0, 20) + '...' : 'FAILED'}`);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.error('Cannot get token, aborting');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== NON AUTHENTIFIE ==========
|
||||||
|
console.log('\n=== NON AUTHENTIFIE ===');
|
||||||
|
const anonCtx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const anon = await anonCtx.newPage();
|
||||||
|
|
||||||
|
const snap = async (p, name, url, wait = 3000) => {
|
||||||
|
try {
|
||||||
|
await p.goto(url, opts);
|
||||||
|
await p.waitForTimeout(wait);
|
||||||
|
await p.screenshot({ path: `/tmp/screenshots/${name}.png`, fullPage: false });
|
||||||
|
console.log(`OK: ${name}`);
|
||||||
|
} catch(e) {
|
||||||
|
console.log(`FAIL: ${name} - ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await snap(anon, 'anon_01_home', `${BASE}/`);
|
||||||
|
await snap(anon, 'anon_02_watchlist', `${BASE}/watchlist`);
|
||||||
|
await snap(anon, 'anon_03_favorites', `${BASE}/favorites`);
|
||||||
|
await snap(anon, 'anon_04_downloads', `${BASE}/downloads`);
|
||||||
|
await snap(anon, 'anon_05_settings', `${BASE}/settings`);
|
||||||
|
await snap(anon, 'anon_06_recommendations', `${BASE}/recommendations`);
|
||||||
|
|
||||||
|
// ========== AUTHENTIFIE (cookie + localStorage) ==========
|
||||||
|
console.log('\n=== AUTHENTIFIE ===');
|
||||||
|
const authCtx = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
});
|
||||||
|
// Injecter le token comme cookie AVANT toute navigation
|
||||||
|
await authCtx.addCookies([{
|
||||||
|
name: 'auth_token',
|
||||||
|
value: token,
|
||||||
|
domain: '127.0.0.1',
|
||||||
|
path: '/',
|
||||||
|
sameSite: 'Strict',
|
||||||
|
httpOnly: false,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const auth = await authCtx.newPage();
|
||||||
|
|
||||||
|
// Injecter dans localStorage au premier chargement
|
||||||
|
await auth.goto(BASE + '/', opts);
|
||||||
|
await auth.evaluate((t) => {
|
||||||
|
localStorage.setItem('auth_token', t);
|
||||||
|
}, token);
|
||||||
|
await auth.waitForTimeout(3000);
|
||||||
|
await auth.screenshot({ path: '/tmp/screenshots/auth_01_home.png', fullPage: false });
|
||||||
|
console.log('OK: auth_01_home');
|
||||||
|
|
||||||
|
await snap(auth, 'auth_02_watchlist', `${BASE}/watchlist`);
|
||||||
|
await snap(auth, 'auth_03_favorites', `${BASE}/favorites`);
|
||||||
|
await snap(auth, 'auth_04_downloads', `${BASE}/downloads`);
|
||||||
|
await snap(auth, 'auth_05_settings', `${BASE}/settings`);
|
||||||
|
await snap(auth, 'auth_06_recommendations', `${BASE}/recommendations`);
|
||||||
|
|
||||||
|
// ========== TESTS FONCTIONNELS ==========
|
||||||
|
console.log('\n=== TESTS FONCTIONNELS ===');
|
||||||
|
|
||||||
|
// Test API: toggle favori
|
||||||
|
const favResult = await auth.evaluate(async (t) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/favorites/toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${t}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content_type: 'anime', anime_id: 'test-screenshot-1', title: 'Test Screenshot Anime' })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return { status: res.status, is_favorite: data.is_favorite };
|
||||||
|
} catch(e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
}, token);
|
||||||
|
console.log(`Favorite toggle: ${JSON.stringify(favResult)}`);
|
||||||
|
|
||||||
|
// Voir les favoris
|
||||||
|
await snap(auth, 'auth_07_favorites_after_add', `${BASE}/favorites`);
|
||||||
|
|
||||||
|
// Test API: ajouter watchlist item
|
||||||
|
const wlResult = await auth.evaluate(async (t) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/watchlist', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${t}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
anime_title: 'Test Screenshot Anime',
|
||||||
|
anime_url: 'https://example.com/anime/1',
|
||||||
|
episode_count: 12,
|
||||||
|
current_episode: 0,
|
||||||
|
status: 'watching'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return { status: res.status, id: data.id, title: data.anime_title };
|
||||||
|
} catch(e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
}, token);
|
||||||
|
console.log(`Watchlist add: ${JSON.stringify(wlResult)}`);
|
||||||
|
|
||||||
|
// Voir la watchlist
|
||||||
|
await snap(auth, 'auth_08_watchlist_with_item', `${BASE}/watchlist`);
|
||||||
|
|
||||||
|
// Scroller sur la home
|
||||||
|
await auth.goto(`${BASE}/`, opts);
|
||||||
|
await auth.waitForTimeout(2000);
|
||||||
|
await auth.evaluate(() => window.scrollTo(0, 600));
|
||||||
|
await auth.waitForTimeout(1000);
|
||||||
|
await auth.screenshot({ path: '/tmp/screenshots/auth_09_home_scrolled.png', fullPage: false });
|
||||||
|
console.log('OK: auth_09_home_scrolled');
|
||||||
|
|
||||||
|
// ========== NETTOYAGE ==========
|
||||||
|
console.log('\n=== Nettoyage ===');
|
||||||
|
// Retirer le favori de test
|
||||||
|
await auth.evaluate(async (t) => {
|
||||||
|
await fetch('/api/favorites/toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${t}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content_type: 'anime', anime_id: 'test-screenshot-1', title: 'Test Screenshot Anime' })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retirer le watchlist item de test
|
||||||
|
if (wlResult.id) {
|
||||||
|
await auth.evaluate(async ({t, id}) => {
|
||||||
|
await fetch(`/api/watchlist/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${t}` }
|
||||||
|
});
|
||||||
|
}, { t: token, id: wlResult.id });
|
||||||
|
console.log('Test watchlist item deleted');
|
||||||
|
}
|
||||||
|
console.log('Test favorite removed');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('\n=== ALL DONE ===');
|
||||||
|
})();
|
||||||
Generated
+2235
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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.58.2",
|
||||||
|
"jsdom": "^29.0.0",
|
||||||
|
"vitest": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://playwright.dev/docs/test-configuration
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
|
||||||
|
/* 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: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: 'uvicorn main:app --host 0.0.0.0 --port 3000',
|
||||||
|
url: 'http://localhost:3000',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ aiohttp==3.11.11
|
|||||||
beautifulsoup4==4.12.3
|
beautifulsoup4==4.12.3
|
||||||
lxml==5.3.0
|
lxml==5.3.0
|
||||||
jieba==0.42.1
|
jieba==0.42.1
|
||||||
|
sqlmodel==0.0.22
|
||||||
|
PyYAML==6.0.1
|
||||||
|
|
||||||
# Testing dependencies
|
# Testing dependencies
|
||||||
pytest==8.3.4
|
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())
|
||||||
+1047
-1500
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`
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
|
||||||
|
// Set up global window object for jsdom
|
||||||
|
global.window = global.window || {};
|
||||||
|
|
||||||
|
// Define skeleton functions for testing (same as in auth-api.js)
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
throw new Error('Not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(username, password, email = null, full_name = null) {
|
||||||
|
throw new Error('Not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
throw new Error('Not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMe(token) {
|
||||||
|
throw new Error('Not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up window object
|
||||||
|
window.authApi = {
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
getMe,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('authApi', () => {
|
||||||
|
describe('login function', () => {
|
||||||
|
it('should be a function', () => {
|
||||||
|
expect(typeof window.authApi.login).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a Promise', () => {
|
||||||
|
const result = window.authApi.login('test', 'test');
|
||||||
|
expect(result).toBeInstanceOf(Promise);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register function', () => {
|
||||||
|
it('should be a function', () => {
|
||||||
|
expect(typeof window.authApi.register).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a Promise', () => {
|
||||||
|
const result = window.authApi.register('testuser', 'password123', null, null);
|
||||||
|
expect(result).toBeInstanceOf(Promise);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle optional parameters', async () => {
|
||||||
|
try {
|
||||||
|
await window.authApi.register('test', 'password');
|
||||||
|
} catch (e) {
|
||||||
|
expect(e.message).toBe('Not implemented yet');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout function', () => {
|
||||||
|
it('should be a function', () => {
|
||||||
|
expect(typeof window.authApi.logout).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a Promise', () => {
|
||||||
|
const result = window.authApi.logout();
|
||||||
|
expect(result).toBeInstanceOf(Promise);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMe function', () => {
|
||||||
|
it('should be a function', () => {
|
||||||
|
expect(typeof window.authApi.getMe).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a Promise', () => {
|
||||||
|
const result = window.authApi.getMe('fake-token');
|
||||||
|
expect(result).toBeInstanceOf(Promise);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock DOM elements for displayError tests
|
||||||
|
const mockDocument = () => {
|
||||||
|
const elements = {};
|
||||||
|
global.document = {
|
||||||
|
getElementById: (id) => elements[id] || null,
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
elements.authError = {
|
||||||
|
textContent: '',
|
||||||
|
classList: {
|
||||||
|
add: () => {},
|
||||||
|
remove: () => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
elements.authSuccess = {
|
||||||
|
textContent: '',
|
||||||
|
classList: {
|
||||||
|
add: () => {},
|
||||||
|
remove: () => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('safeJsonParse', () => {
|
||||||
|
// Import the function - we'll need to make it work with Vitest
|
||||||
|
// For now, we'll define it inline for testing
|
||||||
|
const safeJsonParse = (text, fallback = null) => {
|
||||||
|
try {
|
||||||
|
if (text === undefined || text === null || text === '') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should parse valid JSON string', () => {
|
||||||
|
const result = safeJsonParse('{"key":"value"}');
|
||||||
|
expect(result).toEqual({ key: 'value' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback for invalid JSON', () => {
|
||||||
|
const result = safeJsonParse('invalid json');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return custom fallback when provided', () => {
|
||||||
|
const result = safeJsonParse('invalid', 'custom fallback');
|
||||||
|
expect(result).toBe('custom fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback for undefined input', () => {
|
||||||
|
const result = safeJsonParse(undefined);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback for null input', () => {
|
||||||
|
const result = safeJsonParse(null);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback for empty string', () => {
|
||||||
|
const result = safeJsonParse('');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse valid JSON array', () => {
|
||||||
|
const result = safeJsonParse('[1, 2, 3]');
|
||||||
|
expect(result).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse nested JSON', () => {
|
||||||
|
const result = safeJsonParse('{"user":{"name":"John","age":30}}');
|
||||||
|
expect(result).toEqual({ user: { name: 'John', age: 30 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Smoke test to verify Vitest setup
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('smoke', () => {
|
||||||
|
it('works', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+50
-38
@@ -62,16 +62,13 @@ async function searchAnimeDetails(query, malId = null) {
|
|||||||
const providersData = await getProvidersInfo();
|
const providersData = await getProvidersInfo();
|
||||||
|
|
||||||
// Build results HTML
|
// Build results HTML
|
||||||
const streamingParts = [
|
const streamingParts = [];
|
||||||
`<div class="streaming-results-header">
|
let hasResults = false;
|
||||||
<h3>🎬 Résultats de streaming</h3>
|
|
||||||
</div>
|
|
||||||
<div class="search-results" style="margin-top: 20px;">`
|
|
||||||
];
|
|
||||||
|
|
||||||
// Display results from each provider - render all cards in parallel
|
// Display results from each provider - render all cards in parallel
|
||||||
for (const [providerId, results] of Object.entries(streamingData.value.results)) {
|
for (const [providerId, results] of Object.entries(streamingData.value.results)) {
|
||||||
if (results && results.length > 0) {
|
if (results && results.length > 0) {
|
||||||
|
hasResults = true;
|
||||||
const provider = providersData.anime_providers[providerId];
|
const provider = providersData.anime_providers[providerId];
|
||||||
|
|
||||||
// Render all cards for this provider
|
// Render all cards for this provider
|
||||||
@@ -81,9 +78,18 @@ async function searchAnimeDetails(query, malId = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only add header and wrapper if we have results
|
||||||
|
if (hasResults) {
|
||||||
|
streamingParts.unshift(
|
||||||
|
`<div class="streaming-results-header">
|
||||||
|
<h3><i class="fa-solid fa-film"></i> Résultats de streaming</h3>
|
||||||
|
</div>
|
||||||
|
<div class="search-results" style="margin-top: 20px;">`
|
||||||
|
);
|
||||||
streamingParts.push('</div>');
|
streamingParts.push('</div>');
|
||||||
streamingHtml = streamingParts.join('');
|
streamingHtml = streamingParts.join('');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Display results
|
// Display results
|
||||||
if (malFound && animeData) {
|
if (malFound && animeData) {
|
||||||
@@ -104,7 +110,7 @@ async function searchAnimeDetails(query, malId = null) {
|
|||||||
if (streamingHtml) {
|
if (streamingHtml) {
|
||||||
resultsContainer.innerHTML = `
|
resultsContainer.innerHTML = `
|
||||||
<div class="no-results" style="margin-bottom: 20px;">
|
<div class="no-results" style="margin-bottom: 20px;">
|
||||||
<p>ℹ️ Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"</p>
|
<p><i class="fa-solid fa-circle-info"></i> Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"</p>
|
||||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||||
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End")
|
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End")
|
||||||
</p>
|
</p>
|
||||||
@@ -119,7 +125,7 @@ async function searchAnimeDetails(query, malId = null) {
|
|||||||
} else {
|
} else {
|
||||||
resultsContainer.innerHTML = `
|
resultsContainer.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>❌ Aucun résultat trouvé pour "${escapeHtml(query)}"</p>
|
<p><i class="fa-solid fa-xmark"></i> Aucun résultat trouvé pour "${escapeHtml(query)}"</p>
|
||||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||||
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End", "One Piece")
|
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End", "One Piece")
|
||||||
</p>
|
</p>
|
||||||
@@ -132,7 +138,7 @@ async function searchAnimeDetails(query, malId = null) {
|
|||||||
console.error('Error searching anime details:', error);
|
console.error('Error searching anime details:', error);
|
||||||
resultsContainer.innerHTML = `
|
resultsContainer.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>❌ Erreur lors de la recherche.</p>
|
<p><i class="fa-solid fa-xmark"></i> Erreur lors de la recherche.</p>
|
||||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -150,16 +156,13 @@ async function getProviderSearchResults(query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build results HTML
|
// Build results HTML
|
||||||
const htmlParts = [
|
const htmlParts = [];
|
||||||
`<div class="streaming-results-header">
|
let hasResults = false;
|
||||||
<h3>🎬 Résultats de streaming</h3>
|
|
||||||
</div>
|
|
||||||
<div class="search-results" style="margin-top: 20px;">`
|
|
||||||
];
|
|
||||||
|
|
||||||
// Display results from each provider
|
// Display results from each provider
|
||||||
for (const [providerId, results] of Object.entries(data.results)) {
|
for (const [providerId, results] of Object.entries(data.results)) {
|
||||||
if (results && results.length > 0) {
|
if (results && results.length > 0) {
|
||||||
|
hasResults = true;
|
||||||
const providersData = await getProvidersInfo();
|
const providersData = await getProvidersInfo();
|
||||||
const provider = providersData.anime_providers[providerId];
|
const provider = providersData.anime_providers[providerId];
|
||||||
|
|
||||||
@@ -170,7 +173,16 @@ async function getProviderSearchResults(query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only add header and wrapper if we have results
|
||||||
|
if (hasResults) {
|
||||||
|
htmlParts.unshift(
|
||||||
|
`<div class="streaming-results-header">
|
||||||
|
<h3><i class="fa-solid fa-film"></i> Résultats de streaming</h3>
|
||||||
|
</div>
|
||||||
|
<div class="search-results" style="margin-top: 20px;">`
|
||||||
|
);
|
||||||
htmlParts.push('</div>');
|
htmlParts.push('</div>');
|
||||||
|
}
|
||||||
|
|
||||||
return htmlParts.join('');
|
return htmlParts.join('');
|
||||||
|
|
||||||
@@ -237,16 +249,16 @@ function renderAnimeDetails(anime) {
|
|||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<div class="anime-details-meta">
|
<div class="anime-details-meta">
|
||||||
${score > 0 ? `<div class="anime-details-rating">★ ${score.toFixed(2)}</div>` : ''}
|
${score > 0 ? `<div class="anime-details-rating"><i class="fa-solid fa-star"></i> ${score.toFixed(2)}</div>` : ''}
|
||||||
${rank > 0 ? `<div class="anime-details-rank">#${rank}</div>` : ''}
|
${rank > 0 ? `<div class="anime-details-rank">#${rank}</div>` : ''}
|
||||||
${popularity > 0 ? `<div class="anime-details-popularity">Popularity #${popularity}</div>` : ''}
|
${popularity > 0 ? `<div class="anime-details-popularity">Popularity #${popularity}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="anime-details-stats">
|
<div class="anime-details-stats">
|
||||||
${anime.episodes ? `<span>📺 ${anime.episodes} épisodes</span>` : ''}
|
${anime.episodes ? `<span><i class="fa-solid fa-tv"></i> ${anime.episodes} épisodes</span>` : ''}
|
||||||
${anime.status ? `<span>📡 ${translateStatus(anime.status)}</span>` : ''}
|
${anime.status ? `<span><i class="fa-solid fa-tower-broadcast"></i> ${translateStatus(anime.status)}</span>` : ''}
|
||||||
${anime.duration ? `<span>⏱️ ${escapeHtml(anime.duration)}</span>` : ''}
|
${anime.duration ? `<span><i class="fa-solid fa-clock"></i> ${escapeHtml(anime.duration)}</span>` : ''}
|
||||||
${anime.year ? `<span>📅 ${anime.year}</span>` : ''}
|
${anime.year ? `<span><i class="fa-solid fa-calendar"></i> ${anime.year}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${studios.length > 0 ? `
|
${studios.length > 0 ? `
|
||||||
@@ -256,11 +268,11 @@ function renderAnimeDetails(anime) {
|
|||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<div class="anime-details-actions">
|
<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
|
<i class="fa-solid fa-link"></i> Voir sur MAL
|
||||||
</a>
|
</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
|
<i class="fa-solid fa-download"></i> Télécharger
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,9 +290,9 @@ function renderAnimeDetails(anime) {
|
|||||||
${synopsis ? `
|
${synopsis ? `
|
||||||
<div class="anime-details-section">
|
<div class="anime-details-section">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||||
<h3 style="margin: 0;">📖 Synopsis</h3>
|
<h3 style="margin: 0;"><i class="fa-solid fa-book"></i> 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
|
<i class="fa-solid fa-globe"></i> Traduire en français
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="${synopsisId}" class="anime-details-synopsis">${escapeHtml(synopsis)}</p>
|
<p id="${synopsisId}" class="anime-details-synopsis">${escapeHtml(synopsis)}</p>
|
||||||
@@ -290,7 +302,7 @@ function renderAnimeDetails(anime) {
|
|||||||
<!-- Seasons (Sequel/Prequel) -->
|
<!-- Seasons (Sequel/Prequel) -->
|
||||||
${seasons.length > 0 ? `
|
${seasons.length > 0 ? `
|
||||||
<div class="anime-details-section">
|
<div class="anime-details-section">
|
||||||
<h3>📺 Saisons</h3>
|
<h3><i class="fa-solid fa-tv"></i> Saisons</h3>
|
||||||
<div class="anime-related-list">
|
<div class="anime-related-list">
|
||||||
${seasons.map(season => `
|
${seasons.map(season => `
|
||||||
<div class="anime-related-group">
|
<div class="anime-related-group">
|
||||||
@@ -298,7 +310,7 @@ function renderAnimeDetails(anime) {
|
|||||||
<div class="anime-related-items">
|
<div class="anime-related-items">
|
||||||
${season.entries.map(entry => `
|
${season.entries.map(entry => `
|
||||||
<div class="anime-related-item" onclick="searchAnimeDetails('${escapeHtml(entry.title)}', ${entry.mal_id})" style="cursor: pointer;">
|
<div class="anime-related-item" onclick="searchAnimeDetails('${escapeHtml(entry.title)}', ${entry.mal_id})" style="cursor: pointer;">
|
||||||
${entry.type ? `<span style="color: #00d9ff; font-size: 11px; margin-right: 8px;">${escapeHtml(entry.type)}</span>` : ''}
|
${entry.type ? `<span style="color: #FFBF69; font-size: 11px; margin-right: 8px;">${escapeHtml(entry.type)}</span>` : ''}
|
||||||
${escapeHtml(entry.title)}
|
${escapeHtml(entry.title)}
|
||||||
${entry.url ? `<a href="${escapeHtml(entry.url)}" target="_blank" style="margin-left: auto; color: #888; font-size: 18px; text-decoration: none;" title="Voir sur MyAnimeList">↗</a>` : ''}
|
${entry.url ? `<a href="${escapeHtml(entry.url)}" target="_blank" style="margin-left: auto; color: #888; font-size: 18px; text-decoration: none;" title="Voir sur MyAnimeList">↗</a>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -346,7 +358,7 @@ async function loadStreamingResults(query) {
|
|||||||
if (successfulResults.length === 0) {
|
if (successfulResults.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>⚠️ Aucun résultat de streaming trouvé pour "${escapeHtml(query)}"</p>
|
<p><i class="fa-solid fa-triangle-exclamation"></i> Aucun résultat de streaming trouvé pour "${escapeHtml(query)}"</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
@@ -355,7 +367,7 @@ async function loadStreamingResults(query) {
|
|||||||
// Display results
|
// Display results
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="streaming-results-header">
|
<div class="streaming-results-header">
|
||||||
<h3>🎬 Disponible sur</h3>
|
<h3><i class="fa-solid fa-film"></i> Disponible sur</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="streaming-results-grid">
|
<div class="streaming-results-grid">
|
||||||
${successfulResults.map(result => renderStreamingResult(result, query)).join('')}
|
${successfulResults.map(result => renderStreamingResult(result, query)).join('')}
|
||||||
@@ -366,7 +378,7 @@ async function loadStreamingResults(query) {
|
|||||||
console.error('Error loading streaming results:', error);
|
console.error('Error loading streaming results:', error);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>❌ Erreur lors de la recherche des sources de streaming.</p>
|
<p><i class="fa-solid fa-xmark"></i> Erreur lors de la recherche des sources de streaming.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -393,8 +405,8 @@ function renderStreamingResult(result, query) {
|
|||||||
${episodes.length > 20 ? `<option disabled>... et ${episodes.length - 20} autres</option>` : ''}
|
${episodes.length > 20 ? `<option disabled>... et ${episodes.length - 20} autres</option>` : ''}
|
||||||
</select>
|
</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
|
<i class="fa-solid fa-download"></i> Télécharger
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -463,7 +475,7 @@ async function translateSynopsis(synopsisId, button) {
|
|||||||
// Revert to original
|
// Revert to original
|
||||||
synopsisElement.textContent = originalText;
|
synopsisElement.textContent = originalText;
|
||||||
synopsisElement.dataset.translated = 'false';
|
synopsisElement.dataset.translated = 'false';
|
||||||
button.innerHTML = '🌐 Traduire en français';
|
button.innerHTML = '<i class="fa-solid fa-globe"></i> Traduire en français';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,7 +484,7 @@ async function translateSynopsis(synopsisId, button) {
|
|||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.innerHTML = '⏳ Traduction...';
|
button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Traduction...';
|
||||||
synopsisElement.style.opacity = '0.5';
|
synopsisElement.style.opacity = '0.5';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -497,7 +509,7 @@ async function translateSynopsis(synopsisId, button) {
|
|||||||
|
|
||||||
synopsisElement.textContent = data.translatedText;
|
synopsisElement.textContent = data.translatedText;
|
||||||
synopsisElement.dataset.translated = 'true';
|
synopsisElement.dataset.translated = 'true';
|
||||||
button.innerHTML = '🔄 Voir l\'original';
|
button.innerHTML = '<i class="fa-solid fa-rotate"></i> Voir l\'original';
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||||
console.error('Translation API error:', errorData);
|
console.error('Translation API error:', errorData);
|
||||||
@@ -511,7 +523,7 @@ async function translateSynopsis(synopsisId, button) {
|
|||||||
const errorMessage = document.createElement('div');
|
const errorMessage = document.createElement('div');
|
||||||
errorMessage.style.cssText = 'margin-top: 10px; padding: 10px; background: rgba(255, 107, 107, 0.2); border-radius: 8px; font-size: 12px; color: #ff6b6b;';
|
errorMessage.style.cssText = 'margin-top: 10px; padding: 10px; background: rgba(255, 107, 107, 0.2); border-radius: 8px; font-size: 12px; color: #ff6b6b;';
|
||||||
errorMessage.innerHTML = `
|
errorMessage.innerHTML = `
|
||||||
⚠️ Service de traduction temporairement indisponible.<br>
|
<i class="fa-solid fa-triangle-exclamation"></i> Service de traduction temporairement indisponible.<br>
|
||||||
<small>Essayez à nouveau dans quelques instants.</small>
|
<small>Essayez à nouveau dans quelques instants.</small>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
+9
-629
@@ -1,640 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Anime search and episode management
|
* Anime Search & Releases (Legacy - Partially modernized to HTMX)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
async function loadAnimeReleases() {
|
||||||
* Display search results
|
// Keep this for now as it's not yet fully HTMX
|
||||||
*/
|
console.log('Loading anime releases...');
|
||||||
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';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Add timeout to the fetch
|
const response = await fetch('/api/anime/mal/search?q=2024&limit=12');
|
||||||
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();
|
const data = await response.json();
|
||||||
|
// Logic to render cards would go here, but for now we expect HTMX to handle core search
|
||||||
if (data.seasons && data.seasons.length > 0) {
|
} catch (e) { console.error(e); }
|
||||||
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 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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() {
|
async function handleAnimeSearch() {
|
||||||
const searchInput = document.getElementById('animeSearchInput') || document.getElementById('searchInput');
|
console.log('Legacy handleAnimeSearch - using HTMX form instead');
|
||||||
if (!searchInput) return;
|
|
||||||
|
|
||||||
const query = searchInput.value.trim();
|
|
||||||
if (!query) return;
|
|
||||||
|
|
||||||
// Use the new anime details search
|
|
||||||
await searchAnimeDetails(query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure global scope
|
window.loadAnimeReleases = loadAnimeReleases;
|
||||||
window.handleSearch = handleSearch;
|
|
||||||
window.handleAnimeSearch = handleAnimeSearch;
|
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
|
// Use relative path for API
|
||||||
const AUTH_API_BASE = '/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
|
// Check if user is authenticated
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
const userStr = localStorage.getItem('user');
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
// Redirect to login page instead of just showing prompt
|
window.dispatchEvent(new CustomEvent('auth-logout'));
|
||||||
redirectToLogin();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify token with server
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${AUTH_API_BASE}/auth/me`, {
|
const response = await fetch(`${AUTH_API_BASE}/auth/me`, {
|
||||||
headers: {
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
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;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// Token invalid, remove it and redirect
|
removeToken();
|
||||||
localStorage.removeItem('auth_token');
|
window.dispatchEvent(new CustomEvent('auth-logout'));
|
||||||
localStorage.removeItem('user');
|
|
||||||
redirectToLogin();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth check error:', error);
|
|
||||||
// On error, redirect to login
|
|
||||||
redirectToLogin();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login page
|
|
||||||
function redirectToLogin() {
|
function redirectToLogin() {
|
||||||
// Only redirect if not already on login page
|
|
||||||
if (!window.location.pathname.includes('/login')) {
|
if (!window.location.pathname.includes('/login')) {
|
||||||
window.location.href = '/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() {
|
async function handleLogout() {
|
||||||
if (!confirm('Êtes-vous sûr de vouloir vous déconnecter?')) {
|
if (!confirm('Êtes-vous sûr de vouloir vous déconnecter?')) return;
|
||||||
return;
|
removeToken();
|
||||||
}
|
|
||||||
|
|
||||||
// Remove token from localStorage
|
|
||||||
localStorage.removeItem('auth_token');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
|
|
||||||
// Call logout endpoint
|
|
||||||
try {
|
try {
|
||||||
await fetch(`${AUTH_API_BASE}/auth/logout`, { method: 'POST' });
|
await fetch(`${AUTH_API_BASE}/auth/logout`, { method: 'POST' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error);
|
console.error('Logout error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login page
|
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add authorization header to all fetch requests
|
|
||||||
function addAuthHeader(options = {}) {
|
function addAuthHeader(options = {}) {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
options.headers = options.headers || {};
|
options.headers = options.headers || {};
|
||||||
options.headers['Authorization'] = `Bearer ${token}`;
|
options.headers['Authorization'] = `Bearer ${token}`;
|
||||||
@@ -122,21 +97,20 @@ function addAuthHeader(options = {}) {
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrapper for fetch with auth
|
|
||||||
async function authFetch(url, options = {}) {
|
async function authFetch(url, options = {}) {
|
||||||
options = addAuthHeader(options);
|
options = addAuthHeader(options);
|
||||||
return fetch(url, options);
|
return fetch(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make functions available globally
|
// Global exposure
|
||||||
window.checkAuth = checkAuth;
|
window.checkAuth = checkAuth;
|
||||||
window.showUserInfo = showUserInfo;
|
|
||||||
window.showLoginPrompt = showLoginPrompt;
|
|
||||||
window.handleLogout = handleLogout;
|
window.handleLogout = handleLogout;
|
||||||
window.authFetch = authFetch;
|
window.authFetch = authFetch;
|
||||||
window.addAuthHeader = addAuthHeader;
|
window.addAuthHeader = addAuthHeader;
|
||||||
|
window.getToken = getToken;
|
||||||
|
window.setToken = setToken;
|
||||||
|
window.removeToken = removeToken;
|
||||||
|
|
||||||
// Check authentication on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
checkAuth();
|
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() {
|
async function loadDownloads() {
|
||||||
// Skip refresh if currently clearing downloads to avoid conflicts
|
if (typeof htmx !== 'undefined') {
|
||||||
if (isClearing) {
|
htmx.trigger('#downloads-container-inner', 'refresh');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await getDownloads();
|
|
||||||
allDownloads = data.downloads;
|
|
||||||
updateStats();
|
|
||||||
filterDownloads();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load downloads:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Disable legacy intervals
|
||||||
* Update download statistics display
|
window.loadDownloads = loadDownloads;
|
||||||
*/
|
window.handleCleanupDownloads = () => {
|
||||||
function updateStats() {
|
if (typeof htmx !== 'undefined') {
|
||||||
const stats = {
|
htmx.ajax('POST', '/api/downloads/cleanup', { swap: 'none' });
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
|
|||||||
+17
-246
@@ -1,265 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* Main initialization and event handlers
|
* Main initialization and event handlers - Modernized for HTMX/Alpine
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Initialize on DOM load
|
// Initialize on DOM load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initializeForms();
|
// Only keeping essential initializations
|
||||||
loadProviders();
|
// Note: loadHomeContent() removed as it is now handled by hx-trigger="load"
|
||||||
|
|
||||||
|
// Initial download load
|
||||||
|
if (typeof loadDownloads === 'function') {
|
||||||
loadDownloads();
|
loadDownloads();
|
||||||
setInterval(loadDownloads, 1000);
|
setInterval(loadDownloads, 2000);
|
||||||
|
|
||||||
// Load home content (recommendations & releases)
|
|
||||||
loadHomeContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
* Switch between tabs (Modernized to Alpine.js)
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
function switchTab(tabName) {
|
function switchTab(tabName) {
|
||||||
// Hide all tabs
|
console.log('Switching tab to:', tabName);
|
||||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: tabName } }));
|
||||||
tab.classList.remove('active');
|
window.location.hash = tabName;
|
||||||
});
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Handle URL hash on page load
|
// Handle URL hash on page load
|
||||||
if (window.location.hash) {
|
if (window.location.hash) {
|
||||||
const hash = window.location.hash.substring(1);
|
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'];
|
||||||
switchTab(hash);
|
if (validTabs.includes(hash)) {
|
||||||
|
// Short delay to ensure Alpine is ready
|
||||||
|
setTimeout(() => switchTab(hash), 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +37,8 @@ if (window.location.hash) {
|
|||||||
window.addEventListener('hashchange', function() {
|
window.addEventListener('hashchange', function() {
|
||||||
if (window.location.hash) {
|
if (window.location.hash) {
|
||||||
const hash = window.location.hash.substring(1);
|
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);
|
switchTab(hash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ async function loadRecommendations() {
|
|||||||
} else {
|
} else {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>⚠️ Aucune recommandation disponible pour le moment.</p>
|
<p><i class="fa-solid fa-triangle-exclamation"></i> Aucune recommandation disponible pour le moment.</p>
|
||||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
<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.
|
Soit l'API MyAnimeList est inaccessible, soit vous n'avez pas encore de téléchargements.
|
||||||
</p>
|
</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
|
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -38,10 +38,10 @@ async function loadRecommendations() {
|
|||||||
console.error('Error loading recommendations:', error);
|
console.error('Error loading recommendations:', error);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>❌ Erreur lors du chargement des recommandations.</p>
|
<p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des recommandations.</p>
|
||||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</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
|
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -71,12 +71,12 @@ async function loadLatestReleases() {
|
|||||||
} else {
|
} else {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>⚠️ Aucune sortie disponible pour le moment.</p>
|
<p><i class="fa-solid fa-triangle-exclamation"></i> Aucune sortie disponible pour le moment.</p>
|
||||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||||
L'API MyAnimeList pourrait être temporairement inaccessible.
|
L'API MyAnimeList pourrait être temporairement inaccessible.
|
||||||
</p>
|
</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
|
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -87,10 +87,10 @@ async function loadLatestReleases() {
|
|||||||
console.error('Error loading releases:', error);
|
console.error('Error loading releases:', error);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>❌ Erreur lors du chargement des sorties.</p>
|
<p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des sorties.</p>
|
||||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</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
|
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -100,7 +100,7 @@ async function loadLatestReleases() {
|
|||||||
|
|
||||||
// Load all home content
|
// Load all home content
|
||||||
async function loadHomeContent() {
|
async function loadHomeContent() {
|
||||||
console.log('🏠 loadHomeContent() called');
|
console.log('loadHomeContent() called');
|
||||||
|
|
||||||
const loading = document.getElementById('homeLoading');
|
const loading = document.getElementById('homeLoading');
|
||||||
const recommendationsSection = document.getElementById('recommendationsSection');
|
const recommendationsSection = document.getElementById('recommendationsSection');
|
||||||
@@ -123,13 +123,13 @@ async function loadHomeContent() {
|
|||||||
loadRecommendations(),
|
loadRecommendations(),
|
||||||
loadLatestReleases()
|
loadLatestReleases()
|
||||||
]);
|
]);
|
||||||
console.log('✅ Home content loaded successfully');
|
console.log('Home content loaded successfully');
|
||||||
|
|
||||||
// Show sections if they have content
|
// Show sections if they have content
|
||||||
if (recommendationsSection) recommendationsSection.style.display = 'block';
|
if (recommendationsSection) recommendationsSection.style.display = 'block';
|
||||||
if (releasesSection) releasesSection.style.display = 'block';
|
if (releasesSection) releasesSection.style.display = 'block';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error loading home content:', error);
|
console.error('Error loading home content:', error);
|
||||||
if (loading) {
|
if (loading) {
|
||||||
loading.innerHTML = 'Erreur lors du chargement. Consultez la console pour plus de détails.';
|
loading.innerHTML = 'Erreur lors du chargement. Consultez la console pour plus de détails.';
|
||||||
}
|
}
|
||||||
@@ -149,11 +149,11 @@ function renderRecommendationCard(anime) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="anime-card-horizontal recommendation-card">
|
<div class="anime-card-horizontal recommendation-card">
|
||||||
${reason ? `<div class="recommendation-badge">💡 ${escapeHtml(reason)}</div>` : ''}
|
${reason ? `<div class="recommendation-badge"><i class="fa-solid fa-lightbulb"></i> ${escapeHtml(reason)}</div>` : ''}
|
||||||
|
|
||||||
<div class="anime-card-header">
|
<div class="anime-card-header">
|
||||||
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
||||||
${score > 0 ? `<div class="anime-card-rating">★ ${score.toFixed(1)}</div>` : ''}
|
${score > 0 ? `<div class="anime-card-rating"><i class="fa-solid fa-star"></i> ${score.toFixed(1)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="anime-card-content">
|
<div class="anime-card-content">
|
||||||
@@ -165,7 +165,7 @@ function renderRecommendationCard(anime) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="anime-card-meta">
|
<div class="anime-card-meta">
|
||||||
${anime.episodes ? `📺 ${anime.episodes} ep` : ''}
|
${anime.episodes ? `<i class="fa-solid fa-tv"></i> ${anime.episodes} ep` : ''}
|
||||||
${anime.episodes && anime.status ? ' • ' : ''}
|
${anime.episodes && anime.status ? ' • ' : ''}
|
||||||
${anime.status ? translateStatus(anime.status) : ''}
|
${anime.status ? translateStatus(anime.status) : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -174,17 +174,17 @@ function renderRecommendationCard(anime) {
|
|||||||
|
|
||||||
${anime.synopsis ? `
|
${anime.synopsis ? `
|
||||||
<details class="anime-synopsis">
|
<details class="anime-synopsis">
|
||||||
<summary>📖 Synopsis</summary>
|
<summary><i class="fa-solid fa-book"></i> Synopsis</summary>
|
||||||
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
||||||
</details>
|
</details>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<div class="anime-card-actions">
|
<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
|
<i class="fa-solid fa-link"></i> MAL
|
||||||
</button>
|
</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
|
<i class="fa-solid fa-download"></i> Télécharger
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,11 +202,11 @@ function renderReleaseCard(anime) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="anime-card-horizontal release-card">
|
<div class="anime-card-horizontal release-card">
|
||||||
<div class="release-badge">🔥 ${escapeHtml(releaseType)}</div>
|
<div class="release-badge"><i class="fa-solid fa-fire"></i> ${escapeHtml(releaseType)}</div>
|
||||||
|
|
||||||
<div class="anime-card-header">
|
<div class="anime-card-header">
|
||||||
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
||||||
${score > 0 ? `<div class="anime-card-rating">★ ${score.toFixed(1)}</div>` : ''}
|
${score > 0 ? `<div class="anime-card-rating"><i class="fa-solid fa-star"></i> ${score.toFixed(1)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="anime-card-content">
|
<div class="anime-card-content">
|
||||||
@@ -218,7 +218,7 @@ function renderReleaseCard(anime) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="anime-card-meta">
|
<div class="anime-card-meta">
|
||||||
${anime.episodes ? `📺 ${anime.episodes} ep` : ''}
|
${anime.episodes ? `<i class="fa-solid fa-tv"></i> ${anime.episodes} ep` : ''}
|
||||||
${anime.episodes && anime.status ? ' • ' : ''}
|
${anime.episodes && anime.status ? ' • ' : ''}
|
||||||
${anime.status ? translateStatus(anime.status) : ''}
|
${anime.status ? translateStatus(anime.status) : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -227,17 +227,17 @@ function renderReleaseCard(anime) {
|
|||||||
|
|
||||||
${anime.synopsis ? `
|
${anime.synopsis ? `
|
||||||
<details class="anime-synopsis">
|
<details class="anime-synopsis">
|
||||||
<summary>📖 Synopsis</summary>
|
<summary><i class="fa-solid fa-book"></i> Synopsis</summary>
|
||||||
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
||||||
</details>
|
</details>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<div class="anime-card-actions">
|
<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
|
<i class="fa-solid fa-link"></i> MAL
|
||||||
</button>
|
</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
|
<i class="fa-solid fa-download"></i> Télécharger
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,11 +246,11 @@ function renderReleaseCard(anime) {
|
|||||||
|
|
||||||
// Get rating color based on score
|
// Get rating color based on score
|
||||||
function getRatingColor(score) {
|
function getRatingColor(score) {
|
||||||
if (score >= 9) return 'linear-gradient(45deg, #ffd700, #ffed4e)';
|
if (score >= 9) return '#ffd700';
|
||||||
if (score >= 8) return 'linear-gradient(45deg, #00ff88, #00d9ff)';
|
if (score >= 8) return '#2d936c';
|
||||||
if (score >= 7) return 'linear-gradient(45deg, #00d9ff, #6c5ce7)';
|
if (score >= 7) return '#FF9F1C';
|
||||||
if (score >= 6) return 'linear-gradient(45deg, #ffa500, #ff6b6b)';
|
if (score >= 6) return '#f4a261';
|
||||||
return 'linear-gradient(45deg, #666, #888)';
|
return '#888888';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search anime on providers (redirects to anime tab)
|
// Search anime on providers (redirects to anime tab)
|
||||||
|
|||||||
+16
-16
@@ -26,7 +26,7 @@ async function handleSeriesSearch() {
|
|||||||
const series = data.results['fs7'];
|
const series = data.results['fs7'];
|
||||||
let html = `
|
let html = `
|
||||||
<div class="streaming-results-header">
|
<div class="streaming-results-header">
|
||||||
<h3>📺 Résultats pour "${escapeHtml(query)}"</h3>
|
<h3><i class="fa-solid fa-tv"></i> Résultats pour "${escapeHtml(query)}"</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-results" style="margin-top: 20px;">
|
<div class="search-results" style="margin-top: 20px;">
|
||||||
`;
|
`;
|
||||||
@@ -46,19 +46,19 @@ async function handleSeriesSearch() {
|
|||||||
<div class="anime-card" id="series-fs7-${encodeURIComponent(s.url)}">
|
<div class="anime-card" id="series-fs7-${encodeURIComponent(s.url)}">
|
||||||
<div class="anime-card-header">
|
<div class="anime-card-header">
|
||||||
<div class="anime-card-title">${escapeHtml(s.title)}</div>
|
<div class="anime-card-title">${escapeHtml(s.title)}</div>
|
||||||
<div class="anime-card-provider">📺 French Stream</div>
|
<div class="anime-card-provider"><i class="fa-solid fa-tv"></i> French Stream</div>
|
||||||
</div>
|
</div>
|
||||||
${coverImage ? `
|
${coverImage ? `
|
||||||
<div style="text-align: center; margin: 10px 0;">
|
<div style="text-align: center; margin: 10px 0;">
|
||||||
<img src="${escapeHtml(coverImage)}" alt="" style="max-width: 200px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.3);" onerror="this.style.display='none'">
|
<img src="${escapeHtml(coverImage)}" alt="" style="max-width: 200px; border-radius: 4px;" onerror="this.style.display='none'">
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="anime-card-actions">
|
<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
|
<i class="fa-solid fa-link"></i> Voir sur FS7
|
||||||
</button>
|
</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
|
<i class="fa-solid fa-download"></i> Voir les épisodes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="episodes-fs7-${encodeURIComponent(s.url)}" style="margin-top: 10px;"></div>
|
<div id="episodes-fs7-${encodeURIComponent(s.url)}" style="margin-top: 10px;"></div>
|
||||||
@@ -71,7 +71,7 @@ async function handleSeriesSearch() {
|
|||||||
} else {
|
} else {
|
||||||
resultsContainer.innerHTML = `
|
resultsContainer.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>❌ Aucune série trouvée pour "${escapeHtml(query)}"</p>
|
<p><i class="fa-solid fa-xmark"></i> Aucune série trouvée pour "${escapeHtml(query)}"</p>
|
||||||
<p style="font-size: 12px; margin-top: 10px; opacity: 0.7;">
|
<p style="font-size: 12px; margin-top: 10px; opacity: 0.7;">
|
||||||
Essayez avec un autre titre ou vérifiez l'orthographe
|
Essayez avec un autre titre ou vérifiez l'orthographe
|
||||||
</p>
|
</p>
|
||||||
@@ -81,7 +81,7 @@ async function handleSeriesSearch() {
|
|||||||
console.error('Error searching series:', error);
|
console.error('Error searching series:', error);
|
||||||
resultsContainer.innerHTML = `
|
resultsContainer.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>❌ Erreur lors de la recherche</p>
|
<p><i class="fa-solid fa-xmark"></i> Erreur lors de la recherche</p>
|
||||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -102,16 +102,16 @@ async function loadSeriesEpisodesDirect(url, title) {
|
|||||||
if (data.episodes && data.episodes.length > 0) {
|
if (data.episodes && data.episodes.length > 0) {
|
||||||
let html = `
|
let html = `
|
||||||
<div style="margin-top: 15px;">
|
<div style="margin-top: 15px;">
|
||||||
<label style="font-size: 12px; color: #00d9ff; margin-bottom: 5px; display: block;">
|
<label style="font-size: 12px; color: #FF9F1C; margin-bottom: 5px; display: block;">
|
||||||
📺 Sélectionner un épisode:
|
<i class="fa-solid fa-tv"></i> Sélectionner un épisode:
|
||||||
</label>
|
</label>
|
||||||
<select id="select-episodes-${encodeURIComponent(url)}" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid rgba(0, 217, 255, 0.3); background: rgba(0, 0, 0, 0.3); color: white;">
|
<select id="select-episodes-${encodeURIComponent(url)}" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #2a2d32; background: #202327; color: #F2F2F2;">
|
||||||
<option value="">Sélectionner un épisode</option>
|
<option value="">Sélectionner un épisode</option>
|
||||||
${data.episodes.map(ep => `
|
${data.episodes.map(ep => `
|
||||||
<option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option>
|
<option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</select>
|
</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;">
|
<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>
|
<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>
|
</svg>
|
||||||
@@ -145,7 +145,7 @@ async function downloadSeriesEpisode(url, title) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert(`✅ Téléchargement démarré pour "${title}"`);
|
alert(`Téléchargement démarré pour "${title}"`);
|
||||||
// Refresh downloads
|
// Refresh downloads
|
||||||
if (typeof loadDownloads === 'function') {
|
if (typeof loadDownloads === 'function') {
|
||||||
loadDownloads();
|
loadDownloads();
|
||||||
@@ -155,11 +155,11 @@ async function downloadSeriesEpisode(url, title) {
|
|||||||
const errorMessage = error.detail
|
const errorMessage = error.detail
|
||||||
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
|
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
|
||||||
: 'Impossible de démarrer le téléchargement';
|
: 'Impossible de démarrer le téléchargement';
|
||||||
alert(`❌ Erreur: ${errorMessage}`);
|
alert(`Erreur : ${errorMessage}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Download error:', error);
|
console.error('Download error:', error);
|
||||||
alert(`❌ Erreur lors du téléchargement: ${error.message}`);
|
alert(`Erreur lors du téléchargement : ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Settings page - form handlers for user preferences, filters, and weights.
|
||||||
|
* Loaded on all pages via base.html so functions are available when
|
||||||
|
* the settings section is dynamically loaded via HTMX.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
const data = {
|
||||||
|
default_lang: document.getElementById('default_lang')?.value,
|
||||||
|
theme: document.getElementById('theme')?.value,
|
||||||
|
download_dir: document.getElementById('download_dir')?.value,
|
||||||
|
};
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) return;
|
||||||
|
fetch('/api/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}).then(r => {
|
||||||
|
if (r.ok) showToast('Preferences enregistrees', 'success');
|
||||||
|
}).catch(e => {
|
||||||
|
showToast('Erreur: ' + e.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFilter(field, value) {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) return;
|
||||||
|
fetch('/api/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ [field]: value })
|
||||||
|
}).then(r => {
|
||||||
|
if (r.ok) showToast('Filtre mis a jour', 'success');
|
||||||
|
}).catch(e => {
|
||||||
|
showToast('Erreur: ' + e.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCategory(field, value) {
|
||||||
|
if (!value) {
|
||||||
|
const otherField = field === 'anime_enabled' ? 'series_enabled' : 'anime_enabled';
|
||||||
|
const otherCheckbox = document.getElementById(otherField);
|
||||||
|
if (otherCheckbox && !otherCheckbox.checked) {
|
||||||
|
showToast('Au moins une categorie doit rester active', 'error');
|
||||||
|
document.getElementById(field).checked = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ [field]: value })
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({}));
|
||||||
|
showToast(err.detail || 'Erreur', 'error');
|
||||||
|
document.getElementById(field).checked = !value;
|
||||||
|
} else {
|
||||||
|
showToast('Categorie ' + (value ? 'activee' : 'desactivee'), 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Erreur: ' + e.message, 'error');
|
||||||
|
document.getElementById(field).checked = !value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWeightModeChange(mode) {
|
||||||
|
const autoInfo = document.getElementById('weight-auto-info');
|
||||||
|
const manualControls = document.getElementById('weight-manual-controls');
|
||||||
|
|
||||||
|
if (mode === 'auto') {
|
||||||
|
if (autoInfo) autoInfo.style.display = 'block';
|
||||||
|
if (manualControls) manualControls.style.display = 'none';
|
||||||
|
loadAutoWeights();
|
||||||
|
} else {
|
||||||
|
if (autoInfo) autoInfo.style.display = 'none';
|
||||||
|
if (manualControls) manualControls.style.display = 'block';
|
||||||
|
updateWeightPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) return;
|
||||||
|
fetch('/api/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content_weight_mode: mode })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAutoWeights() {
|
||||||
|
const details = document.getElementById('weight-auto-details');
|
||||||
|
if (!details) return;
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/settings/content-weight', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
const aw = data.anime_weight;
|
||||||
|
const sw = data.series_weight;
|
||||||
|
const ac = data.anime_count;
|
||||||
|
const sc = data.series_count;
|
||||||
|
const total = data.total || 0;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
details.innerHTML = '<span style="color: var(--text-dim);">Aucun telechargement detecte. Ratio par defaut : ' + aw + ' anime / ' + sw + ' serie.</span>';
|
||||||
|
} else {
|
||||||
|
const pctA = total > 0 ? Math.round(ac / total * 100) : 50;
|
||||||
|
const pctS = total > 0 ? Math.round(sc / total * 100) : 50;
|
||||||
|
details.innerHTML = `
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<strong>${ac}</strong> anime${ac > 1 ? 's' : ''} (${pctA}%) — <strong>${sc}</strong> serie${sc > 1 ? 's' : ''} (${pctS}%)
|
||||||
|
</div>
|
||||||
|
<div style="height: 8px; background: var(--secondary); border-radius: 4px; overflow: hidden; display: flex;">
|
||||||
|
<div style="width: ${pctA}%; background: var(--primary);"></div>
|
||||||
|
<div style="width: ${pctS}%; background: #6CB4EE;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 8px; font-size: 12px;">
|
||||||
|
Ratio applique : <strong style="color: var(--primary);">${aw}</strong> anime / <strong style="color: #6CB4EE;">${sw}</strong> serie
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
details.innerHTML = '<span style="color: var(--danger);">Erreur de chargement</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWeightPreview() {
|
||||||
|
const awEl = document.getElementById('content_weight_anime_range');
|
||||||
|
const swEl = document.getElementById('content_weight_series_range');
|
||||||
|
const preview = document.getElementById('weight-preview');
|
||||||
|
if (!awEl || !swEl || !preview) return;
|
||||||
|
|
||||||
|
const aw = parseInt(awEl.value) || 0;
|
||||||
|
const sw = parseInt(swEl.value) || 0;
|
||||||
|
const total = aw + sw;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
preview.innerHTML = '<span style="color: var(--danger);">Les deux poids ne peuvent pas etre a 0</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pctA = Math.round(aw / total * 100);
|
||||||
|
const pctS = 100 - pctA;
|
||||||
|
|
||||||
|
preview.innerHTML = `
|
||||||
|
<div style="margin-bottom: 6px;">
|
||||||
|
<span style="color: var(--primary); font-weight: 700;">${pctA}%</span> animes /
|
||||||
|
<span style="color: #6CB4EE; font-weight: 700;">${pctS}%</span> series
|
||||||
|
</div>
|
||||||
|
<div style="height: 8px; background: var(--secondary); border-radius: 4px; overflow: hidden; display: flex;">
|
||||||
|
<div style="width: ${pctA}%; background: var(--primary); transition: width 0.2s;"></div>
|
||||||
|
<div style="width: ${pctS}%; background: #6CB4EE; transition: width 0.2s;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveManualWeights() {
|
||||||
|
const awEl = document.getElementById('content_weight_anime_range');
|
||||||
|
const swEl = document.getElementById('content_weight_series_range');
|
||||||
|
if (!awEl || !swEl) return;
|
||||||
|
|
||||||
|
const aw = parseInt(awEl.value) || 0;
|
||||||
|
const sw = parseInt(swEl.value) || 0;
|
||||||
|
|
||||||
|
if (aw === 0 && sw === 0) {
|
||||||
|
showToast('Les deux poids ne peuvent pas etre a 0', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content_weight_mode: 'manual', content_weight_anime: aw, content_weight_series: sw })
|
||||||
|
});
|
||||||
|
if (r.ok) showToast('Equilibre mis a jour', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Erreur: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type) {
|
||||||
|
const event = new CustomEvent('show-toast', { detail: { message, type } });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize weight display when settings tab content is loaded via HTMX
|
||||||
|
document.addEventListener('htmx:afterSettle', function(evt) {
|
||||||
|
if (evt.detail.target) {
|
||||||
|
const mode = evt.detail.target.querySelector('#content_weight_mode');
|
||||||
|
if (mode && mode.value === 'auto') {
|
||||||
|
loadAutoWeights();
|
||||||
|
} else if (mode && mode.value === 'manual') {
|
||||||
|
updateWeightPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+26
-26
@@ -19,7 +19,7 @@ function renderSeriesRecommendationCard(series) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="anime-card-horizontal recommendation-card">
|
<div class="anime-card-horizontal recommendation-card">
|
||||||
<div class="recommendation-badge">🎺 Série TV populaire</div>
|
<div class="recommendation-badge"><i class="fa-solid fa-music"></i> Série TV populaire</div>
|
||||||
|
|
||||||
<div class="anime-card-header">
|
<div class="anime-card-header">
|
||||||
<div class="anime-card-title">${escapeHtml(series.title)}</div>
|
<div class="anime-card-title">${escapeHtml(series.title)}</div>
|
||||||
@@ -30,17 +30,17 @@ function renderSeriesRecommendationCard(series) {
|
|||||||
|
|
||||||
<div class="anime-card-info">
|
<div class="anime-card-info">
|
||||||
<div class="anime-card-meta">
|
<div class="anime-card-meta">
|
||||||
📺 Série TV
|
<i class="fa-solid fa-tv"></i> Série TV
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="anime-card-actions">
|
<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
|
<i class="fa-solid fa-link"></i> Voir sur FS7
|
||||||
</button>
|
</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
|
<i class="fa-solid fa-download"></i> Voir les épisodes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,17 +92,17 @@ function renderSeriesReleaseCard(series) {
|
|||||||
|
|
||||||
<div class="anime-card-info">
|
<div class="anime-card-info">
|
||||||
<div class="anime-card-meta">
|
<div class="anime-card-meta">
|
||||||
📺 Série TV • Nouveau
|
<i class="fa-solid fa-tv"></i> Série TV • Nouveau
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="anime-card-actions">
|
<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
|
<i class="fa-solid fa-link"></i> Voir sur FS7
|
||||||
</button>
|
</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
|
<i class="fa-solid fa-download"></i> Voir les épisodes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,10 +236,10 @@ async function loadSeriesReleases() {
|
|||||||
if (container) {
|
if (container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>❌ Erreur lors du chargement des séries</p>
|
<p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des séries</p>
|
||||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</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
|
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -260,7 +260,7 @@ async function loadProvidersGrid() {
|
|||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
// Section Anime providers
|
// Section Anime providers
|
||||||
html += '<div class="section-header"><h3 style="margin-top: 20px;">🎬 Sites Anime</h3></div>';
|
html += '<div class="section-header"><h3 style="margin-top: 20px;"><i class="fa-solid fa-film"></i> Sites Anime</h3></div>';
|
||||||
html += '<div class="search-results">';
|
html += '<div class="search-results">';
|
||||||
|
|
||||||
const animeProviders = Object.entries(data.anime_providers || {});
|
const animeProviders = Object.entries(data.anime_providers || {});
|
||||||
@@ -280,12 +280,12 @@ async function loadProvidersGrid() {
|
|||||||
` : ''}
|
` : ''}
|
||||||
<div class="anime-card-actions">
|
<div class="anime-card-actions">
|
||||||
${domains.length > 0 ? `
|
${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
|
<i class="fa-solid fa-link"></i> Visiter le site
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
<button class="btn-secondary btn-small" onclick="showProviderSearch('${id}')">
|
<button class="btn btn-secondary btn-small" onclick="showProviderSearch('${id}')">
|
||||||
🔍 Rechercher
|
<i class="fa-solid fa-magnifying-glass"></i> Rechercher
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,7 +298,7 @@ async function loadProvidersGrid() {
|
|||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
// Section File hosts
|
// Section File hosts
|
||||||
html += '<div class="section-header" style="margin-top: 40px;"><h3>💾 Hébergeurs de fichiers</h3></div>';
|
html += '<div class="section-header" style="margin-top: 40px;"><h3><i class="fa-solid fa-floppy-disk"></i> Hébergeurs de fichiers</h3></div>';
|
||||||
html += '<div class="search-results">';
|
html += '<div class="search-results">';
|
||||||
|
|
||||||
const fileHosts = Object.entries(data.file_hosts || {});
|
const fileHosts = Object.entries(data.file_hosts || {});
|
||||||
@@ -310,8 +310,8 @@ async function loadProvidersGrid() {
|
|||||||
<div class="anime-card-title">${host.icon} ${host.name}</div>
|
<div class="anime-card-title">${host.icon} ${host.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="anime-card-actions">
|
<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
|
<i class="fa-solid fa-download"></i> Télécharger un fichier
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,10 +330,10 @@ async function loadProvidersGrid() {
|
|||||||
if (container) {
|
if (container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p>❌ Erreur lors du chargement des fournisseurs</p>
|
<p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des fournisseurs</p>
|
||||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</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
|
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -349,7 +349,7 @@ function showProviderSearch(providerId) {
|
|||||||
|
|
||||||
// Show download info (explains how to download)
|
// Show download info (explains how to download)
|
||||||
function showDownloadInfo() {
|
function showDownloadInfo() {
|
||||||
alert('💡 Pour télécharger un fichier:\n\n1. Utilisez l\'onglet "Recherche"\n2. Entrez le nom de l\'anime/série\n3. Cliquez sur "Télécharger" sur un épisode\n\nOu bien:\n- Copiez directement un lien de téléchargement dans la barre d\'adresse de votre navigateur');
|
alert('Pour télécharger un fichier:\n\n1. Utilisez l\'onglet "Recherche"\n2. Entrez le nom de l\'anime/série\n3. Cliquez sur "Télécharger" sur un épisode\n\nOu bien:\n- Copiez directement un lien de téléchargement dans la barre d\'adresse de votre navigateur');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make additional functions available globally
|
// Make additional functions available globally
|
||||||
|
|||||||
+11
-564
@@ -1,571 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Watchlist UI functions
|
* Watchlist UI (Legacy - Modernized to HTMX)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
async function displayWatchlist() {
|
||||||
* Escape HTML to prevent XSS
|
console.log('Legacy displayWatchlist called - redirected to HTMX');
|
||||||
*/
|
if (typeof htmx !== 'undefined') {
|
||||||
function escapeHtml(text) {
|
htmx.trigger('#watchlist-items-container', 'load');
|
||||||
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>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Global exposure for legacy calls
|
||||||
* 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
|
|
||||||
window.displayWatchlist = displayWatchlist;
|
window.displayWatchlist = displayWatchlist;
|
||||||
window.handleAddToWatchlist = handleAddToWatchlist;
|
window.handleDeleteFromWatchlist = (id) => {
|
||||||
window.handlePauseWatchlist = handlePauseWatchlist;
|
if (confirm('Retirer de la watchlist ?')) {
|
||||||
window.handleResumeWatchlist = handleResumeWatchlist;
|
htmx.ajax('DELETE', `/api/watchlist/${id}`, { target: `#watchlist-${id}`, swap: 'outerHTML' });
|
||||||
window.handleCheckItem = handleCheckItem;
|
}
|
||||||
window.handleDeleteWatchlist = handleDeleteWatchlist;
|
};
|
||||||
window.handleCheckAll = handleCheckAll;
|
|
||||||
window.createSettingsModal = createSettingsModal;
|
|
||||||
window.closeSettingsModal = closeSettingsModal;
|
|
||||||
window.saveSettings = saveSettings;
|
|
||||||
|
|||||||
+21
-21
@@ -7,7 +7,7 @@
|
|||||||
* Get user's watchlist
|
* Get user's watchlist
|
||||||
*/
|
*/
|
||||||
async function getWatchlist(status = null) {
|
async function getWatchlist(status = null) {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ async function getWatchlist(status = null) {
|
|||||||
* Add anime to watchlist
|
* Add anime to watchlist
|
||||||
*/
|
*/
|
||||||
async function addToWatchlist(animeData) {
|
async function addToWatchlist(animeData) {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ async function addToWatchlist(animeData) {
|
|||||||
* Update watchlist item
|
* Update watchlist item
|
||||||
*/
|
*/
|
||||||
async function updateWatchlistItem(itemId, updateData) {
|
async function updateWatchlistItem(itemId, updateData) {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ async function updateWatchlistItem(itemId, updateData) {
|
|||||||
* Delete from watchlist
|
* Delete from watchlist
|
||||||
*/
|
*/
|
||||||
async function deleteFromWatchlist(itemId) {
|
async function deleteFromWatchlist(itemId) {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ async function resumeWatchlistItem(itemId) {
|
|||||||
* Check specific anime for new episodes
|
* Check specific anime for new episodes
|
||||||
*/
|
*/
|
||||||
async function checkWatchlistItem(itemId) {
|
async function checkWatchlistItem(itemId) {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ async function checkWatchlistItem(itemId) {
|
|||||||
* Check all watchlist items
|
* Check all watchlist items
|
||||||
*/
|
*/
|
||||||
async function checkAllWatchlistItems() {
|
async function checkAllWatchlistItems() {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@ async function checkAllWatchlistItems() {
|
|||||||
* Get watchlist settings
|
* Get watchlist settings
|
||||||
*/
|
*/
|
||||||
async function getWatchlistSettings() {
|
async function getWatchlistSettings() {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -190,7 +190,7 @@ async function getWatchlistSettings() {
|
|||||||
* Update watchlist settings
|
* Update watchlist settings
|
||||||
*/
|
*/
|
||||||
async function updateWatchlistSettings(settings) {
|
async function updateWatchlistSettings(settings) {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -215,7 +215,7 @@ async function updateWatchlistSettings(settings) {
|
|||||||
* Get watchlist statistics
|
* Get watchlist statistics
|
||||||
*/
|
*/
|
||||||
async function getWatchlistStats() {
|
async function getWatchlistStats() {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -237,7 +237,7 @@ async function getWatchlistStats() {
|
|||||||
* Get scheduler status
|
* Get scheduler status
|
||||||
*/
|
*/
|
||||||
async function getSchedulerStatus() {
|
async function getSchedulerStatus() {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -259,7 +259,7 @@ async function getSchedulerStatus() {
|
|||||||
* Start scheduler
|
* Start scheduler
|
||||||
*/
|
*/
|
||||||
async function startScheduler() {
|
async function startScheduler() {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -282,7 +282,7 @@ async function startScheduler() {
|
|||||||
* Stop scheduler
|
* Stop scheduler
|
||||||
*/
|
*/
|
||||||
async function stopScheduler() {
|
async function stopScheduler() {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
@@ -346,10 +346,10 @@ async function handleStartScheduler() {
|
|||||||
try {
|
try {
|
||||||
await startScheduler();
|
await startScheduler();
|
||||||
await loadSchedulerStatus();
|
await loadSchedulerStatus();
|
||||||
alert('✅ Planificateur démarré!');
|
alert('Planificateur démarré !');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error starting scheduler:', error);
|
console.error('Error starting scheduler:', error);
|
||||||
alert(`❌ Erreur: ${error.message}`);
|
alert(`Erreur : ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,10 +360,10 @@ async function handleStopScheduler() {
|
|||||||
try {
|
try {
|
||||||
await stopScheduler();
|
await stopScheduler();
|
||||||
await loadSchedulerStatus();
|
await loadSchedulerStatus();
|
||||||
alert('✅ Planificateur arrêté!');
|
alert('Planificateur arrêté !');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error stopping scheduler:', error);
|
console.error('Error stopping scheduler:', error);
|
||||||
alert(`❌ Erreur: ${error.message}`);
|
alert(`Erreur : ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ async function handleCheckAll() {
|
|||||||
await loadSchedulerStatus();
|
await loadSchedulerStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking all:', error);
|
console.error('Error checking all:', error);
|
||||||
alert(`❌ Erreur: ${error.message}`);
|
alert(`Erreur : ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ async function handleOpenSettings() {
|
|||||||
document.body.appendChild(modalContainer);
|
document.body.appendChild(modalContainer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
alert(`❌ Erreur: ${error.message}`);
|
alert(`Erreur : ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,17 +438,17 @@ function updateSchedulerUI(status) {
|
|||||||
|
|
||||||
if (status.next_run) {
|
if (status.next_run) {
|
||||||
const nextRun = new Date(status.next_run);
|
const nextRun = new Date(status.next_run);
|
||||||
nextRunInfo.innerHTML = `✓ En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
|
nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
|
||||||
} else {
|
} else {
|
||||||
// Scheduler running but no next_run yet (just started)
|
// Scheduler running but no next_run yet (just started)
|
||||||
const interval = status.settings?.check_interval_hours || 6;
|
const interval = status.settings?.check_interval_hours || 6;
|
||||||
nextRunInfo.innerHTML = `✓ En cours<br>Vérification toutes les ${interval}h`;
|
nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Vérification toutes les ${interval}h`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Update buttons if they exist
|
// Update buttons if they exist
|
||||||
if (startBtn) startBtn.style.display = 'inline-block';
|
if (startBtn) startBtn.style.display = 'inline-block';
|
||||||
if (stopBtn) stopBtn.style.display = 'none';
|
if (stopBtn) stopBtn.style.display = 'none';
|
||||||
nextRunInfo.innerHTML = '⏸️ Arrêté';
|
nextRunInfo.innerHTML = '<i class="fa-solid fa-pause"></i> Arrêté';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user