Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b12d06160 | |||
| 819acf04f8 | |||
| a7145aabd1 | |||
| 535005b3d5 | |||
| 4101d98a41 | |||
| 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 | |||
| 20bcc75b9b | |||
| 4c96d0c1c5 | |||
| fae465699b | |||
| 611f36aa2b | |||
| bc3c93d7da | |||
| 3f87df685b | |||
| a2ff8e547f | |||
| 3b4997213b | |||
| 13b017a206 | |||
| 24567b58cf | |||
| a91ff3f71b | |||
| a49831f65e | |||
| 5d50c32bfd | |||
| e3135c99cb | |||
| 7eef8aaff1 | |||
| 2188298217 | |||
| e22bc4191c | |||
| 36ec4a0eee | |||
| d19a9c4a76 |
@@ -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
|
||||||
@@ -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/
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"active_plan": "/opt/Ohm_streaming/.sisyphus/plans/cors-fix.md",
|
||||||
|
"started_at": "2026-03-18T13:17:43.401Z",
|
||||||
|
"session_ids": [
|
||||||
|
"ses_3388359e2ffe5brQanNc9Qb8FL"
|
||||||
|
],
|
||||||
|
"plan_name": "cors-fix",
|
||||||
|
"agent": "atlas"
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Draft: Anime-Sama Player Fallback System
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- **Mode**: Automatique - essayer tous les players jusqu'à en trouver un qui fonctionne
|
||||||
|
- **Success Criterion**: Test téléchargement (télécharger un petit chunk pour vérifier)
|
||||||
|
- **Workflow**: Si le player détecté échoue, essayer VidMoly, SendVid, Sibnet, etc. automatiquement
|
||||||
|
|
||||||
|
## Technical Decisions
|
||||||
|
|
||||||
|
### Player Priority Order (for Anime-Sama fallback)
|
||||||
|
1. VidMoly - most reliable
|
||||||
|
2. SendVid - second most reliable
|
||||||
|
3. Sibnet - third
|
||||||
|
4. Lpayer - last (requires Playwright, slower)
|
||||||
|
|
||||||
|
### Success Detection
|
||||||
|
- Download first 10KB of the video
|
||||||
|
- If successful (200 OK, valid data), consider player working
|
||||||
|
- Cache which player works for future episodes
|
||||||
|
|
||||||
|
### Implementation Approach
|
||||||
|
1. Add `get_download_link_with_fallback()` method in `AnimeSamaDownloader`
|
||||||
|
2. Test each player by downloading first 10KB
|
||||||
|
3. Use first player that returns valid data
|
||||||
|
4. Cache working player per anime URL/series
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- INCLUDE: Anime-Sama downloader with automatic player fallback
|
||||||
|
- INCLUDE: Video URL validation via chunk download test
|
||||||
|
- INCLUDE: Player caching for performance
|
||||||
|
- EXCLUDE: Frontend UI changes (backend only)
|
||||||
|
- EXCLUDE: Other anime sites (Anime-Sama only for now)
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
- `app/downloaders/anime_sites/animesama.py` - Add fallback logic
|
||||||
|
- `app/downloaders/base.py` - May need base helper method
|
||||||
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 421 KiB |
@@ -0,0 +1 @@
|
|||||||
|
364: window.watchlistTabLoaded = false;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Evidence: Task 1 - Timeout URL Test
|
||||||
|
|
||||||
|
## Scenario: Invalid video URL times out
|
||||||
|
|
||||||
|
**Tool**: Python3
|
||||||
|
**Preconditions**: URL that times out (httpbin.org/delay/20)
|
||||||
|
**Steps**:
|
||||||
|
1. python3 -c "from app.downloaders.anime_sites.animesama import AnimeSamaDownloader; d = AnimeSamaDownloader(); result = d._test_video_url('https://httpbin.org/delay/20'); print(f'Result: {result}')"
|
||||||
|
|
||||||
|
**Expected Result**: Returns False (timeout)
|
||||||
|
|
||||||
|
**Actual Result**:
|
||||||
|
Video URL validation FAILED: Timeout for https://httpbin.org/delay/20...
|
||||||
|
Result for timeout URL: False
|
||||||
|
|
||||||
|
**Status**: PASS
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Evidence: Task 1 - Valid URL Test
|
||||||
|
|
||||||
|
## Scenario: Valid video URL returns 200 OK
|
||||||
|
|
||||||
|
**Tool**: Python3
|
||||||
|
**Preconditions**: URL that returns HTTP 200
|
||||||
|
**Steps**:
|
||||||
|
1. python3 -c "from app.downloaders.anime_sites.animesama import AnimeSamaDownloader; d = AnimeSamaDownloader(); result = d._test_video_url('https://www.google.com/'); print(f'Result: {result}')"
|
||||||
|
|
||||||
|
**Expected Result**: Returns True
|
||||||
|
|
||||||
|
**Actual Result**:
|
||||||
|
Result for google.com: True
|
||||||
|
|
||||||
|
**Status**: PASS
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Evidence: Task 2 - All Players Fail
|
||||||
|
|
||||||
|
## Scenario: All players fail
|
||||||
|
|
||||||
|
**Tool**: Python3
|
||||||
|
**Preconditions**: Mock all extractions to fail
|
||||||
|
**Steps**:
|
||||||
|
1. Mock all _extract_from_* methods to raise Exception
|
||||||
|
2. Call get_download_link_with_fallback()
|
||||||
|
|
||||||
|
**Expected Result**: Raises exception "All video players failed"
|
||||||
|
|
||||||
|
**Actual Result**:
|
||||||
|
Exception raised: All players failed. Last error: Player failed
|
||||||
|
|
||||||
|
**Status**: PASS
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# CSS Class Conflicts Check Results
|
||||||
|
|
||||||
|
## Check 4: filter-tab class in style.css
|
||||||
|
No matches found for "filter-tab" in static/css/style.css
|
||||||
|
|
||||||
|
However, filter-tab IS defined in watchlist.html inline styles:
|
||||||
|
/opt/Ohm_streaming/templates/watchlist.html
|
||||||
|
123: .filter-tabs {
|
||||||
|
130: .filter-tab {
|
||||||
|
140: .filter-tab:hover {
|
||||||
|
144: .filter-tab.active {
|
||||||
|
257: <div class="filter-tabs">
|
||||||
|
258: <button class="filter-tab active" onclick="filterWatchlist('all', this)">Tous</button>
|
||||||
|
259: <button class="filter-tab" onclick="filterWatchlist('active', this)">Actifs</button>
|
||||||
|
260: <button class="filter-tab" onclick="filterWatchlist('paused', this)">En pause</button>
|
||||||
|
261: <button class="filter-tab" onclick="filterWatchlist('completed', this)">Terminés</button>
|
||||||
|
363: document.querySelectorAll('.filter-tab').forEach(tab => {
|
||||||
|
|
||||||
|
## Check 5: .tab class in style.css
|
||||||
|
Found 2 matches in static/css/style.css
|
||||||
|
151: .tab {
|
||||||
|
733: .tab {
|
||||||
|
|
||||||
|
## Tab Class Usage Across Templates:
|
||||||
|
- login.html: auth-tabs, auth-tab
|
||||||
|
- watchlist.html: .tab (navigation), .filter-tabs, .filter-tab
|
||||||
|
- components/header.html: .tab (navigation tabs)
|
||||||
|
|
||||||
|
## Potential CSS Conflict Analysis:
|
||||||
|
1. filter-tab: Defined inline in watchlist.html, NOT in style.css
|
||||||
|
- Risk: LOW (isolated to watchlist page)
|
||||||
|
|
||||||
|
2. .tab: Defined in style.css at lines 151 and 733
|
||||||
|
- Used in multiple templates for navigation tabs
|
||||||
|
- .filter-tab is DIFFERENT from .tab
|
||||||
|
- Risk: LOW (.tab and .filter-tab are distinct classes)
|
||||||
|
|
||||||
|
## Conclusion:
|
||||||
|
NO CSS CLASS CONFLICTS DETECTED
|
||||||
|
- filter-tab is isolated to watchlist.html (inline CSS)
|
||||||
|
- .tab class in style.css is for main navigation tabs
|
||||||
|
- .filter-tab is a separate, distinct class for watchlist filtering
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# DOM ID Conflicts Check Results
|
||||||
|
|
||||||
|
## Check 1: watchlistContainer & schedulerStatus
|
||||||
|
Found 2 matches in 1 file(s):
|
||||||
|
/opt/Ohm_streaming/templates/watchlist.html
|
||||||
|
233: <div class="scheduler-status" id="schedulerStatus">
|
||||||
|
265: <div id="watchlistContainer">
|
||||||
|
|
||||||
|
## Check 2: settingsModal & nextRunInfo
|
||||||
|
Found 2 matches in 1 file(s):
|
||||||
|
/opt/Ohm_streaming/templates/watchlist.html
|
||||||
|
237: <div id="nextRunInfo" class="next-run-info">Chargement...</div>
|
||||||
|
422: <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;">
|
||||||
|
|
||||||
|
## Check 3: startSchedulerBtn & stopSchedulerBtn
|
||||||
|
Found 2 matches in 1 file(s):
|
||||||
|
/opt/Ohm_streaming/templates/watchlist.html
|
||||||
|
240: <button id="startSchedulerBtn" class="btn-primary btn-small" onclick="handleStartScheduler()" style="display:none;">
|
||||||
|
243: <button id="stopSchedulerBtn" class="btn-secondary btn-small" onclick="handleStopScheduler()" style="display:none;">
|
||||||
|
|
||||||
|
## Conflict Analysis:
|
||||||
|
All IDs are unique to watchlist.html only. No conflicts found with other templates.
|
||||||
|
|
||||||
|
Checked templates:
|
||||||
|
- login.html (auth-tabs, auth-tab)
|
||||||
|
- index.html (tab-anime, tab-series, tab-providers)
|
||||||
|
- components/header.html (mainTabs, tab-home, tab-anime, tab-series, etc.)
|
||||||
|
- components/home_section.html (tab-home)
|
||||||
|
- watchlist.html (these IDs are local to this file)
|
||||||
|
|
||||||
|
## Conclusion:
|
||||||
|
NO DOM ID CONFLICTS DETECTED
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Evidence: Task 2 - First Player Works
|
||||||
|
|
||||||
|
## Scenario: First player (VidMoly) works
|
||||||
|
|
||||||
|
**Tool**: Python3
|
||||||
|
**Preconditions**: Mock VidMoly URL that passes validation
|
||||||
|
**Steps**:
|
||||||
|
1. Mock _test_video_url to return True
|
||||||
|
2. Mock _extract_from_vidmoly to return valid URL
|
||||||
|
3. Call get_download_link_with_fallback()
|
||||||
|
|
||||||
|
**Expected Result**: Returns VidMoly URL, logs "VidMoly player succeeded"
|
||||||
|
|
||||||
|
**Actual Result**:
|
||||||
|
Video URL: https://vidmoly.to/video.mp4
|
||||||
|
Filename: vidmoly_video.mp4
|
||||||
|
Used player: VidMoly
|
||||||
|
|
||||||
|
**Status**: PASS
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Evidence: Task 2 - Second Player Works
|
||||||
|
|
||||||
|
## Scenario: First player fails, second works
|
||||||
|
|
||||||
|
**Tool**: Python3
|
||||||
|
**Preconditions**: Mock VidMoly to fail, SendVid to succeed
|
||||||
|
**Steps**:
|
||||||
|
1. Mock _extract_from_vidmoly to raise Exception
|
||||||
|
2. Mock _extract_from_sendvid to return valid URL
|
||||||
|
3. Mock _test_video_url to return True
|
||||||
|
4. Call get_download_link_with_fallback()
|
||||||
|
|
||||||
|
**Expected Result**: Returns SendVid URL (VidMoly failed, SendVid succeeded)
|
||||||
|
|
||||||
|
**Actual Result**:
|
||||||
|
Video URL: https://sendvid.com/video.mp4
|
||||||
|
Used player: SendVid
|
||||||
|
|
||||||
|
**Status**: PASS
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Evidence: Task 3 - Direct URL Skips Fallback
|
||||||
|
|
||||||
|
## Scenario: Direct video URL skips fallback
|
||||||
|
|
||||||
|
**Tool**: Python3
|
||||||
|
**Preconditions**: Anime-Sama downloader with fallback method
|
||||||
|
**Steps**:
|
||||||
|
1. Mock get_download_link_with_fallback
|
||||||
|
2. Call get_download_link() with direct URL (no pipe)
|
||||||
|
|
||||||
|
**Expected Result**: Fallback method is NOT called (False) - direct extraction used
|
||||||
|
|
||||||
|
**Actual Result**:
|
||||||
|
Fallback called: False
|
||||||
|
Result: ('https://direct.mp4', 'direct.mp4')
|
||||||
|
|
||||||
|
**Status**: PASS
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Evidence: Task 3 - Pipe URL Triggers Fallback
|
||||||
|
|
||||||
|
## Scenario: Pipe URL triggers fallback
|
||||||
|
|
||||||
|
**Tool**: Python3
|
||||||
|
**Preconditions**: Anime-Sama downloader with fallback method
|
||||||
|
**Steps**:
|
||||||
|
1. Mock get_download_link_with_fallback
|
||||||
|
2. Call get_download_link() with pipe URL
|
||||||
|
|
||||||
|
**Expected Result**: Fallback method is called (True)
|
||||||
|
|
||||||
|
**Actual Result**:
|
||||||
|
Fallback called: True
|
||||||
|
|
||||||
|
**Status**: PASS
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# JavaScript Duplication Audit Report
|
||||||
|
|
||||||
|
**Generated:** 2026-02-26
|
||||||
|
**Scope:** static/js/**/*.js (13 files)
|
||||||
|
**Files Audited:** api.js, utils.js, auth.js, main.js, tabs.js, anime.js, series-search.js, downloads.js, watchlist/main.js, anime-details.js, recommendations.js, watchlist.js, watchlist-ui.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL DUPLICATIONS (Potential Syntax Errors)
|
||||||
|
|
||||||
|
### 1. translateStatus() Function - DUPLICATED DEFINITION
|
||||||
|
- **File 1:** `static/js/utils.js:35` - Primary definition
|
||||||
|
- **File 2:** `static/js/anime-details.js:428` - Duplicate definition
|
||||||
|
|
||||||
|
**Impact:** HIGH - If both files are loaded, the second definition will overwrite the first, causing unpredictable behavior. The utils.js version is used by downloads.js and recommendations.js, while anime-details.js has its own localized version.
|
||||||
|
|
||||||
|
**Recommendation:** Remove duplicate in anime-details.js and ensure anime-details.js imports from utils.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MINOR DUPLICATIONS (Non-Breaking)
|
||||||
|
|
||||||
|
### 2. Redundant const Declarations in Same Function Scope (Different Functions)
|
||||||
|
|
||||||
|
#### auth.js - Duplicate variable declarations across functions
|
||||||
|
- `mainContent` declared at line 70 and line 76 (in different functions showMainContent/hideMainContent)
|
||||||
|
- `userInfo` declared at line 57 and line 82 (in showUserInfo/showLoginPrompt)
|
||||||
|
- `loginPrompt` declared at line 58 and line 83
|
||||||
|
- `mainTabs` declared at line 59 and line 84
|
||||||
|
|
||||||
|
**Impact:** LOW - These are in different function scopes, not causing syntax errors but creating redundant code
|
||||||
|
|
||||||
|
#### recommendations.js - Duplicate variable names in different functions
|
||||||
|
- `container` declared at lines 5, 54, 105 (in different functions)
|
||||||
|
- `section` declared at lines 6, 55 (in different functions)
|
||||||
|
|
||||||
|
**Impact:** LOW - Different function scopes
|
||||||
|
|
||||||
|
#### tabs.js - Duplicate container variable
|
||||||
|
- `container` declared at lines 115, 152, 160, 178, 186, 235, 252, 329
|
||||||
|
|
||||||
|
**Impact:** LOW - Different function scopes
|
||||||
|
|
||||||
|
#### anime.js - Duplicate variable names across functions
|
||||||
|
- `selectElement` declared at lines 156, 245, 253, 261, 307, 352
|
||||||
|
- `seasonSelectElement` declared at lines 156, 245
|
||||||
|
- `actionsDiv` declared at lines 287, 325
|
||||||
|
|
||||||
|
**Impact:** LOW - Different function scopes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PATTERN OBSERVATIONS
|
||||||
|
|
||||||
|
### Utility Functions Shared Across Files
|
||||||
|
The following functions are defined once but used across multiple files:
|
||||||
|
- `escapeHtml()` - Defined in utils.js:26, used in 8 files
|
||||||
|
- `translateStatus()` - DEFINED TWICE (CRITICAL ISSUE)
|
||||||
|
- `formatBytes()` - Defined in utils.js
|
||||||
|
- `formatSpeed()` - Defined in utils.js
|
||||||
|
- `extractSeriesName()` - Defined in utils.js
|
||||||
|
- `getDayString()` - Defined in utils.js
|
||||||
|
|
||||||
|
### Cross-File Function Usage
|
||||||
|
- `renderReleaseCard()` - Defined in recommendations.js:195, called in tabs.js:171
|
||||||
|
- `renderAnimeCard()` - Defined in anime.js:58, called in anime-details.js
|
||||||
|
- `loadDownloads()` - Defined in downloads.js, called from multiple files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SUMMARY
|
||||||
|
|
||||||
|
| Severity | Count | Issue |
|
||||||
|
|----------|-------|-------|
|
||||||
|
| CRITICAL | 1 | translateStatus() defined twice (utils.js + anime-details.js) |
|
||||||
|
| MINOR | 4+ | Redundant const declarations across functions (auth.js) |
|
||||||
|
| MINOR | 3+ | Duplicate container/section variables (recommendations.js, tabs.js, anime.js) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RECOMMENDATIONS
|
||||||
|
|
||||||
|
1. **FIX CRITICAL:** Remove duplicate `translateStatus()` from anime-details.js and use the version from utils.js
|
||||||
|
2. **Consider:** Consolidating utility functions into a single utils module that all files import
|
||||||
|
3. **Future Cleanup:** Review auth.js for redundant variable declarations (minor optimization)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VERIFICATION
|
||||||
|
|
||||||
|
Audit completed: 13 JavaScript files scanned
|
||||||
|
Duplicate function definitions: 1 CRITICAL
|
||||||
|
Redundant const declarations: Multiple (non-critical)
|
||||||
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
@@ -0,0 +1,111 @@
|
|||||||
|
# Task 5: Watchlist API Structure Documentation
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
`http://localhost:3000/api/watchlist`
|
||||||
|
|
||||||
|
## Available Endpoints
|
||||||
|
|
||||||
|
### 1. GET /api/watchlist
|
||||||
|
- **Description**: List all watchlist items for current user
|
||||||
|
- **Auth**: Required (JWT Bearer token)
|
||||||
|
- **Query Params**:
|
||||||
|
- `status` (optional): Filter by status (active, paused, completed, archived)
|
||||||
|
- **Response 200**: `{"watchlist": [], "total": 0, "filters": {"status": null}}`
|
||||||
|
- **Response 403**: `{"detail": "Not authenticated"}`
|
||||||
|
|
||||||
|
### 2. POST /api/watchlist
|
||||||
|
- **Description**: Add a new anime to the watchlist
|
||||||
|
- **Auth**: Required
|
||||||
|
- **Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"anime_title": "string",
|
||||||
|
"anime_url": "string",
|
||||||
|
"provider_id": "string",
|
||||||
|
"lang": "vostfr",
|
||||||
|
"auto_download": true,
|
||||||
|
"quality_preference": "auto",
|
||||||
|
"poster_image": "string (optional)",
|
||||||
|
"cover_image": "string (optional)",
|
||||||
|
"synopsis": "string (optional)",
|
||||||
|
"genres": ["string"] (optional)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Response**: `{"status": "added", "item": {...}}`
|
||||||
|
|
||||||
|
### 3. GET /api/watchlist/{item_id}
|
||||||
|
- **Description**: Get details of a specific watchlist item
|
||||||
|
- **Auth**: Required
|
||||||
|
- **Response 200**: `{"item": {...}}`
|
||||||
|
- **Response 404**: `{"detail": "Watchlist item not found"}`
|
||||||
|
- **Response 403**: `{"detail": "Access denied"}`
|
||||||
|
|
||||||
|
### 4. PUT /api/watchlist/{item_id}
|
||||||
|
- **Description**: Update a watchlist item
|
||||||
|
- **Auth**: Required
|
||||||
|
- **Response**: `{"status": "updated", "item": {...}}`
|
||||||
|
|
||||||
|
### 5. DELETE /api/watchlist/{item_id}
|
||||||
|
- **Description**: Remove an anime from the watchlist
|
||||||
|
- **Auth**: Required
|
||||||
|
- **Response**: `{"status": "deleted", "item_id": "string"}`
|
||||||
|
|
||||||
|
### 6. GET /api/watchlist/{item_id}/episodes
|
||||||
|
- **Description**: Get all downloaded episodes for a watchlist item
|
||||||
|
- **Auth**: Required
|
||||||
|
|
||||||
|
### 7. POST /api/watchlist/{item_id}/download/{episode}
|
||||||
|
- **Description**: Download a specific episode
|
||||||
|
- **Auth**: Required
|
||||||
|
- **Response**: `{"status": "downloading", "task_id": "string", "episode": int, "item_id": "string"}`
|
||||||
|
|
||||||
|
### 8. GET /api/watchlist/stats ⚠️ BUG
|
||||||
|
- **Description**: Get watchlist statistics
|
||||||
|
- **Auth**: Required
|
||||||
|
- **Expected Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 0,
|
||||||
|
"active": 0,
|
||||||
|
"paused": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"archived": 0,
|
||||||
|
"auto_download_enabled": 0,
|
||||||
|
"total_episodes_downloaded": 0,
|
||||||
|
"providers": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Actual Response**: 404 "Watchlist item not found" (ROUTING BUG)
|
||||||
|
|
||||||
|
### 9. GET /api/watchlist/settings ⚠️ BUG
|
||||||
|
- **Description**: Get watchlist settings
|
||||||
|
- **Auth**: Required
|
||||||
|
- **Expected Response**: `{"settings": {...}}`
|
||||||
|
- **Actual Response**: 404 "Watchlist item not found" (ROUTING BUG)
|
||||||
|
|
||||||
|
### 10. GET /api/watchlist/notifications ⚠️ BUG
|
||||||
|
- **Description**: Get user notifications
|
||||||
|
- **Auth**: Required
|
||||||
|
- **Query Params**: `unread_only` (bool)
|
||||||
|
- **Expected Response**: `{"notifications": [], "total": 0, "unread_only": false}`
|
||||||
|
- **Actual Response**: 404 "Watchlist item not found" (ROUTING BUG)
|
||||||
|
|
||||||
|
### 11. PUT /api/watchlist/notifications/{notification_id}/read
|
||||||
|
- **Description**: Mark a notification as read
|
||||||
|
- **Auth**: Required
|
||||||
|
|
||||||
|
### 12. PUT /api/watchlist/settings
|
||||||
|
- **Description**: Update watchlist settings
|
||||||
|
- **Auth**: Required
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
- Uses JWT Bearer tokens
|
||||||
|
- Token obtained from POST /api/auth/login
|
||||||
|
- Pass as: `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
## Bug Summary
|
||||||
|
- **Issue**: 3 endpoints return 404 instead of correct responses
|
||||||
|
- **Affected**: /stats, /settings, /notifications
|
||||||
|
- **Cause**: Route ordering - `/{item_id}` catch-all defined before these specific routes
|
||||||
|
- **Location**: app/routes/watchlist.py
|
||||||
|
- **Fix needed**: Move specific routes BEFORE the `/{item_id}` route
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Evidence: Task 5 - Integration Test with Real Anime-Sama URL
|
||||||
|
|
||||||
|
## Scenario: Download Frieren S1 E1 with fallback
|
||||||
|
|
||||||
|
**Tool**: curl + API
|
||||||
|
**Preconditions**: Server running, fallback implemented
|
||||||
|
**Steps**:
|
||||||
|
1. Get episodes from anime-sama.tv
|
||||||
|
2. Download episode via API
|
||||||
|
|
||||||
|
**Expected Result**: Download completes successfully
|
||||||
|
|
||||||
|
**Actual Result**:
|
||||||
|
- Download status: COMPLETED
|
||||||
|
- File size: 321MB
|
||||||
|
- File: downloads/Frieren - S1 - Episode 01.mp4
|
||||||
|
- Logs show: Using SendVid for extraction (fallback working)
|
||||||
|
|
||||||
|
**Status**: PASS
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Task 5: GET /api/watchlist Test Results
|
||||||
|
|
||||||
|
## Test Date: 2026-02-26
|
||||||
|
|
||||||
|
## Server Status
|
||||||
|
- Server running on port 3000: ✓
|
||||||
|
- Health check: ✓ PASS
|
||||||
|
|
||||||
|
## Authentication Test
|
||||||
|
- Unauthenticated request to /api/watchlist:
|
||||||
|
- HTTP Status: 403
|
||||||
|
- Response: {"detail":"Not authenticated"}
|
||||||
|
|
||||||
|
- Authenticated request to /api/watchlist:
|
||||||
|
- HTTP Status: 200
|
||||||
|
- Response: {"watchlist":[],"total":0,"filters":{"status":null}}
|
||||||
|
|
||||||
|
## Endpoints Tested
|
||||||
|
|
||||||
|
| Endpoint | Auth | Expected Status | Actual Status | Result |
|
||||||
|
|----------|------|-----------------|---------------|--------|
|
||||||
|
| GET /api/watchlist | No | 401/403 | 403 | ✓ PASS |
|
||||||
|
| GET /api/watchlist | Yes | 200 | 200 | ✓ PASS |
|
||||||
|
| GET /api/watchlist/stats | Yes | 200 | 404 | ✗ FAIL (BUG) |
|
||||||
|
| GET /api/watchlist/settings | Yes | 200 | 404 | ✗ FAIL (BUG) |
|
||||||
|
| GET /api/watchlist/notifications | Yes | 200 | 404 | ✗ FAIL (BUG) |
|
||||||
|
|
||||||
|
## Issue Found
|
||||||
|
|
||||||
|
The following endpoints return 404 "Watchlist item not found" when they should work:
|
||||||
|
- /api/watchlist/stats
|
||||||
|
- /api/watchlist/settings
|
||||||
|
- /api/watchlist/notifications
|
||||||
|
|
||||||
|
**Root Cause**: Route ordering issue in `app/routes/watchlist.py`
|
||||||
|
- The `/{item_id}` catch-all route (line 134) is defined BEFORE the specific routes like `/stats` (line 372), `/settings` (line 335), and `/notifications` (line 285)
|
||||||
|
- FastAPI matches these paths as item IDs instead of the intended routes
|
||||||
|
|
||||||
|
## Test User
|
||||||
|
- Username: watchlist_test
|
||||||
|
- Token: JWT (7-day expiry)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Watchlist Integration Test Results
|
||||||
|
============================================================
|
||||||
|
[PASS] Navigate to /watchlist
|
||||||
|
[PASS] Watchlist tab highlighted
|
||||||
|
[PASS] Header/nav present
|
||||||
|
[PASS] Scheduler panel displays
|
||||||
|
[PASS] Filter tabs present and clickable
|
||||||
|
[PASS] Settings modal works
|
||||||
|
[PASS] Refresh mechanism present
|
||||||
|
[PASS] Tab switching works
|
||||||
|
[PASS] /web#watchlist loads watchlist
|
||||||
|
[PASS] /watchlist page has content
|
||||||
|
============================================================
|
||||||
|
Total: 10/10 tests passed
|
||||||
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 418 KiB |
@@ -0,0 +1,98 @@
|
|||||||
|
## 2026-02-25 Task 1: Add video URL validation helper
|
||||||
|
|
||||||
|
**Task**: Add `_test_video_url()` method to AnimeSamaDownloader
|
||||||
|
|
||||||
|
**What was implemented**:
|
||||||
|
- Method `_test_video_url(url: str) -> bool` added to end of AnimeSamaDownloader class
|
||||||
|
- Downloads first 10KB using HTTP Range header (`bytes=0-10240`)
|
||||||
|
- 10 second timeout handling
|
||||||
|
- Returns True if HTTP 200 and data > 0 bytes
|
||||||
|
- Returns False on timeout, connection error, or empty response
|
||||||
|
- Logs all validation results
|
||||||
|
|
||||||
|
**Issues encountered**:
|
||||||
|
- Subagent created duplicate imports and modified unrelated files
|
||||||
|
- Had to revert changes to other files
|
||||||
|
- Had to fix duplicate logger line
|
||||||
|
- Had to revert unintended get_download_link signature change
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- Valid URL (google.com): Returns True ✓
|
||||||
|
- Timeout URL (httpbin.org/delay/20): Returns False ✓
|
||||||
|
- Method exists: True ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-25 Task 2: Implement player fallback logic
|
||||||
|
|
||||||
|
**Task**: Add `get_download_link_with_fallback()` method with player priority list
|
||||||
|
|
||||||
|
**What was implemented**:
|
||||||
|
- Added `__init__` method with cache initialization: `self._working_players = {}`
|
||||||
|
- Added `get_download_link_with_fallback()` method with:
|
||||||
|
- Player priority list: ['vidmoly', 'sendvid', 'sibnet', 'lpayer']
|
||||||
|
- Tries each player in order
|
||||||
|
- Validates each URL with _test_video_url()
|
||||||
|
- Caches working player per anime URL
|
||||||
|
- Logs each player attempt (success/failure)
|
||||||
|
- Returns (video_url, filename) on first success
|
||||||
|
- Raises exception if all players fail
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- First player works: VidMoly URL returned ✓
|
||||||
|
- First fails, second works: SendVid URL returned ✓
|
||||||
|
- All fail: Exception raised ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-25 Task 3: Integrate fallback into get_download_link()
|
||||||
|
|
||||||
|
**Task**: Update `get_download_link()` to use fallback for pipe-separated URLs
|
||||||
|
|
||||||
|
**What was implemented**:
|
||||||
|
- Modified `get_download_link()` to call `get_download_link_with_fallback()` for pipe-separated URLs
|
||||||
|
- Direct URLs (no pipe) still use existing extraction flow for performance
|
||||||
|
- Backward compatibility maintained
|
||||||
|
- Fixed target_filename parameter to match download_manager expectations
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- Pipe URL triggers fallback: True ✓
|
||||||
|
- Direct URL skips fallback: True ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-25 Task 4: Add unit tests
|
||||||
|
|
||||||
|
**Task**: Create unit tests for fallback logic
|
||||||
|
|
||||||
|
**What was implemented**:
|
||||||
|
- Created `tests/test_anime_sama_fallback.py` with 10 tests:
|
||||||
|
1. test_fallback_tries_players_in_priority_order
|
||||||
|
2. test_caching_mechanism_stores_working_player
|
||||||
|
3. test_all_players_failing_raises_exception
|
||||||
|
4. test_test_video_url_returns_true_for_valid_url
|
||||||
|
5. test_test_video_url_returns_false_for_invalid_url
|
||||||
|
6. test_test_video_url_returns_false_for_empty_response
|
||||||
|
7. test_test_video_url_returns_false_for_timeout
|
||||||
|
8. test_test_video_url_returns_false_for_connection_error
|
||||||
|
9. test_fallback_skips_invalid_player_url
|
||||||
|
10. test_cache_not_used_without_anime_page_url
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- All 10 tests pass: ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-25 Task 5: Integration testing
|
||||||
|
|
||||||
|
**Task**: Test with real Anime-Sama URLs
|
||||||
|
|
||||||
|
**What was implemented**:
|
||||||
|
- Downloaded Frieren S1 E1 from anime-sama.tv
|
||||||
|
- Used pipe-separated URL format
|
||||||
|
- Download completed successfully
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- Download status: COMPLETED ✓
|
||||||
|
- File size: 321MB ✓
|
||||||
|
- Fallback logic working (SendVid used) ✓
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
# Anime-Sama Player Fallback System
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
> **Quick Summary**: Implement automatic player fallback for Anime-Sama downloads to handle cases where the detected player fails
|
||||||
|
>
|
||||||
|
> **Deliverables**:
|
||||||
|
> - `get_download_link_with_fallback()` method in AnimeSamaDownloader
|
||||||
|
> - Player success validation via chunk download test
|
||||||
|
> - Player caching for performance optimization
|
||||||
|
>
|
||||||
|
> **Estimated Effort**: Medium
|
||||||
|
> **Parallel Execution**: NO - sequential implementation
|
||||||
|
> **Critical Path**: Test implementation → AnimeSamaDownloader update → Integration testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Original Request
|
||||||
|
User requested a new feature for Anime-Sama provider: ability to change video player on the site. When a player (like Lpayer) fails, the downloader should automatically test different players until finding one that works.
|
||||||
|
|
||||||
|
### Interview Summary
|
||||||
|
**Key Decisions**:
|
||||||
|
- Mode: Automatic - if Lpayer fails, try VidMoly, SendVid, Sibnet, etc. automatically
|
||||||
|
- Success Criterion: Download test (download first 10KB chunk to verify URL works)
|
||||||
|
- Priority Order: VidMoly → SendVid → Sibnet → Lpayer
|
||||||
|
|
||||||
|
**Technical Requirements**:
|
||||||
|
- Test video URL by downloading small chunk (10KB)
|
||||||
|
- If successful, consider player working
|
||||||
|
- Cache working player per anime/series for future episodes
|
||||||
|
- Automatic retry without user intervention
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Work Objectives
|
||||||
|
|
||||||
|
### Core Objective
|
||||||
|
Implement automatic player fallback in Anime-Sama downloader to handle failed extractions by trying alternative players sequentially.
|
||||||
|
|
||||||
|
### Concrete Deliverables
|
||||||
|
- `AnimeSamaDownloader.get_download_link_with_fallback()` - Main fallback method
|
||||||
|
- `_test_video_url()` - Helper to validate video URL by downloading chunk
|
||||||
|
- Player priority list with caching mechanism
|
||||||
|
- Updated `get_download_link()` to use fallback by default
|
||||||
|
|
||||||
|
### Definition of Done
|
||||||
|
- [ ] Fallback method tries players in priority order
|
||||||
|
- [ ] Video URL validated before returning (10KB download test)
|
||||||
|
- [ ] Working player cached per anime for performance
|
||||||
|
- [ ] All existing Anime-Sama functionality preserved
|
||||||
|
|
||||||
|
### Must Have
|
||||||
|
- Players tested sequentially: VidMoly → SendVid → Sibnet → Lpayer
|
||||||
|
- Success detection via HTTP 200 + valid data download (10KB chunk)
|
||||||
|
- Cache mechanism to avoid re-testing for same anime
|
||||||
|
- Automatic integration with existing download flow
|
||||||
|
|
||||||
|
### Must NOT Have (Guardrails)
|
||||||
|
- NO frontend changes required (backend-only implementation)
|
||||||
|
- NO manual player selection via API (automatic only)
|
||||||
|
- NO changes to other anime sites (Anime-Sama only)
|
||||||
|
- NO breaking changes to existing Anime-Sama functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Strategy (MANDATORY)
|
||||||
|
|
||||||
|
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
|
||||||
|
|
||||||
|
### Test Decision
|
||||||
|
- **Infrastructure exists**: YES
|
||||||
|
- **Automated tests**: Tests-after (unit tests for fallback logic)
|
||||||
|
- **Framework**: pytest
|
||||||
|
|
||||||
|
### QA Policy
|
||||||
|
Every task MUST include agent-executed QA scenarios (see TODO template below).
|
||||||
|
|
||||||
|
- **Unit Tests**: pytest with mocked HTTP clients
|
||||||
|
- **Integration Tests**: Test with real Anime-Sama URLs
|
||||||
|
- **Edge Cases**: All players failing, first player working, cache invalidation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Strategy
|
||||||
|
|
||||||
|
### Sequential Implementation
|
||||||
|
|
||||||
|
Since this is a focused feature on a single file, implementation will be sequential:
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: Add URL validation helper (can test independently)
|
||||||
|
→ _test_video_url(url) method
|
||||||
|
|
||||||
|
Step 2: Implement fallback logic
|
||||||
|
→ get_download_link_with_fallback() method
|
||||||
|
|
||||||
|
Step 3: Integrate with existing flow
|
||||||
|
→ Update get_download_link() to use fallback
|
||||||
|
|
||||||
|
Step 4: Add unit tests
|
||||||
|
→ Test fallback logic and URL validation
|
||||||
|
|
||||||
|
Step 5: Integration testing
|
||||||
|
→ Test with real Anime-Sama URLs (Frieren S2 E1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Matrix
|
||||||
|
|
||||||
|
- **1**: — 2
|
||||||
|
- **2**: — 3
|
||||||
|
- **3**: — 4, 5
|
||||||
|
- **4**: — 5
|
||||||
|
- **5**: Final
|
||||||
|
|
||||||
|
### Agent Dispatch Summary
|
||||||
|
|
||||||
|
- **1**: `quick` - Helper method
|
||||||
|
- **2**: `quick` - Main fallback logic
|
||||||
|
- **3**: `quick` - Integration
|
||||||
|
- **4**: `quick` - Unit tests
|
||||||
|
- **5**: `quick` - Integration testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODOs
|
||||||
|
|
||||||
|
- [x] 1. Add video URL validation helper method
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Add `_test_video_url(url: str) -> bool` method to AnimeSamaDownloader
|
||||||
|
- Download first 10KB of video using self.client
|
||||||
|
- Return True if HTTP 200 and valid data received, False otherwise
|
||||||
|
- Include timeout handling (10 seconds for 10KB)
|
||||||
|
- Log validation results for debugging
|
||||||
|
|
||||||
|
**Must NOT do**:
|
||||||
|
- Download entire video
|
||||||
|
- Change existing player extraction logic
|
||||||
|
|
||||||
|
**Recommended Agent Profile**:
|
||||||
|
> **Category**: `quick`
|
||||||
|
- Reason: Simple helper method, focused task
|
||||||
|
- **Skills**: None needed
|
||||||
|
- **Skills Evaluated but Omitted**:
|
||||||
|
- No additional skills needed
|
||||||
|
|
||||||
|
**Parallelization**:
|
||||||
|
- **Can Run In Parallel**: NO - Sequential
|
||||||
|
- **Parallel Group**: Sequential
|
||||||
|
- **Blocks**: Task 2
|
||||||
|
- **Blocked By**: None
|
||||||
|
|
||||||
|
**References** (CRITICAL):
|
||||||
|
|
||||||
|
> The executor has NO context from your interview. References are their ONLY guide.
|
||||||
|
> Each reference must answer: "What should I look at and WHY?"
|
||||||
|
|
||||||
|
**Pattern References** (existing code to follow):
|
||||||
|
- `app/downloaders/anime_sites/animesama.py:120-150` - Existing video URL extraction methods
|
||||||
|
- `app/downloaders/anime_sites/animesama.py:402-445` - Existing Lplayer extraction pattern
|
||||||
|
|
||||||
|
**API/Type References** (contracts to implement against):
|
||||||
|
- `httpx.AsyncClient.stream()` - For downloading chunks efficiently
|
||||||
|
|
||||||
|
**External References** (libraries and frameworks):
|
||||||
|
- httpx docs: `https://www.python-httpx.org/advanced/#streaming-responses` - Chunked downloads
|
||||||
|
|
||||||
|
**WHY Each Reference Matters**:
|
||||||
|
- Existing extraction methods show how video URLs are currently handled
|
||||||
|
- httpx streaming allows efficient chunk download without loading full video
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
|
||||||
|
> **AGENT-EXECUTABLE VERIFICATION ONLY** — No human action permitted.
|
||||||
|
> Every criterion MUST be verifiable by running a command or using a tool.
|
||||||
|
|
||||||
|
- [ ] `_test_video_url()` method added to AnimeSamaDownloader
|
||||||
|
- [ ] Downloads first 10KB chunk with 10s timeout
|
||||||
|
- [ ] Returns True if HTTP 200 and data > 0 bytes
|
||||||
|
- [ ] Returns False if timeout, error, or empty response
|
||||||
|
- [ ] Logs validation results (success/failure)
|
||||||
|
|
||||||
|
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Scenario: Valid video URL returns 200 OK
|
||||||
|
Tool: Bash (python3)
|
||||||
|
Preconditions: Mock a video URL that returns 200 with data
|
||||||
|
Steps:
|
||||||
|
1. python3 -c "from app.downloaders.anime_sites.animesama import AnimeSamaDownloader; d = AnimeSamaDownloader(); result = d._test_video_url('https://example.com/video.mp4'); print(f'Result: {result}')"
|
||||||
|
Expected Result: Returns True
|
||||||
|
Evidence: .sisyphus/evidence/task-1-valid-url.txt
|
||||||
|
|
||||||
|
Scenario: Invalid video URL times out
|
||||||
|
Tool: Bash (python3)
|
||||||
|
Preconditions: Mock a video URL that times out
|
||||||
|
Steps:
|
||||||
|
1. python3 -c "from app.downloaders.anime_sites.animesama import AnimeSamaDownloader; d = AnimeSamaDownloader(); result = d._test_video_url('https://httpbin.org/delay/20'); print(f'Result: {result}')"
|
||||||
|
Expected Result: Returns False (timeout)
|
||||||
|
Evidence: .sisyphus/evidence/task-1-timeout-url.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Evidence to Capture**:
|
||||||
|
- [ ] Each evidence file named: task-{N}-{scenario-slug}.txt
|
||||||
|
- [ ] Contains test results with True/False output
|
||||||
|
|
||||||
|
**Commit**: YES
|
||||||
|
- Message: `feat(anime-sama): add video URL validation helper method`
|
||||||
|
- Files: `app/downloaders/anime_sites/animesama.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [x] 2. Implement player fallback logic with priority list
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Add `get_download_link_with_fallback(url, target_filename=None, anime_page_url=None, episode_title=None)` method
|
||||||
|
- Define player priority list: ['vidmoly', 'sendvid', 'sibnet', 'lpayer']
|
||||||
|
- For each player in priority order:
|
||||||
|
- Try existing extraction methods (_extract_from_vidmoly, etc.)
|
||||||
|
- If extraction succeeds, validate URL with _test_video_url()
|
||||||
|
- If validation succeeds, return (video_url, filename)
|
||||||
|
- Add player caching: `self._working_players = {}` dict to cache working player per anime URL
|
||||||
|
- If cached player exists for anime, try it first
|
||||||
|
- Log each attempted player with success/failure
|
||||||
|
|
||||||
|
**Must NOT do**:
|
||||||
|
- Modify existing _extract_from_* methods
|
||||||
|
- Break existing Anime-Sama download flow
|
||||||
|
|
||||||
|
**Recommended Agent Profile**:
|
||||||
|
> **Category**: `quick`
|
||||||
|
- Reason: Sequential logic implementation, clear requirements
|
||||||
|
- **Skills**: None needed
|
||||||
|
- **Skills Evaluated but Omitted**:
|
||||||
|
- No additional skills needed
|
||||||
|
|
||||||
|
**Parallelization**:
|
||||||
|
- **Can Run In Parallel**: NO - Depends on Task 1
|
||||||
|
- **Parallel Group**: Sequential
|
||||||
|
- **Blocks**: Task 3
|
||||||
|
- **Blocked By**: Task 1
|
||||||
|
|
||||||
|
**References** (CRITICAL):
|
||||||
|
|
||||||
|
**Pattern References** (existing code to follow):
|
||||||
|
- `app/downloaders/anime_sites/animesama.py:95-170` - VidMoly extraction pattern
|
||||||
|
- `app/downloaders/anime_sites/animesama.py:280-320` - SendVid extraction pattern
|
||||||
|
- `app/downloaders/anime_sites/animesama.py:250-280` - Sibnet extraction pattern
|
||||||
|
- `app/downloaders/anime_sites/animesama.py:402-445` - Lpayer extraction pattern
|
||||||
|
- `app/downloaders/anime_sites/animesama.py:117-120` - Player detection logic
|
||||||
|
|
||||||
|
**WHY Each Reference Matters**:
|
||||||
|
- Existing extraction methods show the interface each player uses
|
||||||
|
- Player detection logic shows how to identify which player URL to extract
|
||||||
|
- Need to understand the signature of each extraction method
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
|
||||||
|
- [ ] `get_download_link_with_fallback()` method added
|
||||||
|
- [ ] Player priority list defined: vidmoly → sendvid → sibnet → lpayer
|
||||||
|
- [ ] Tries each player in order if previous fails
|
||||||
|
- [ ] Validates video URL with _test_video_url() before returning
|
||||||
|
- [ ] Caches working player per anime_page_url
|
||||||
|
- [ ] Logs each player attempt (success/failure)
|
||||||
|
- [ ] Returns (video_url, filename) on first success
|
||||||
|
- [ ] Raises exception if all players fail
|
||||||
|
|
||||||
|
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Scenario: First player (VidMoly) works
|
||||||
|
Tool: Bash (python3)
|
||||||
|
Preconditions: Mock VidMoly URL that passes validation
|
||||||
|
Steps:
|
||||||
|
1. python3 -c "
|
||||||
|
from app.downloaders.anime_sites.animesama import AnimeSamaDownloader
|
||||||
|
d = AnimeSamaDownloader()
|
||||||
|
# Mock _test_video_url to return True
|
||||||
|
original_test = d._test_video_url
|
||||||
|
d._test_video_url = lambda url: True
|
||||||
|
# Call fallback
|
||||||
|
video_url, filename = d.get_download_link_with_fallback('test_url|anime_page|Ep1', episode_title='Episode 1')
|
||||||
|
print(f'Video URL: {video_url[:50] if video_url else None}')
|
||||||
|
print(f'Filename: {filename}')
|
||||||
|
"
|
||||||
|
Expected Result: Returns VidMoly URL, logs "VidMoly player succeeded"
|
||||||
|
Evidence: .sisyphus/evidence/task-2-first-works.txt
|
||||||
|
|
||||||
|
Scenario: First player fails, second works
|
||||||
|
Tool: Bash (python3)
|
||||||
|
Preconditions: Mock VidMoly to fail, SendVid to succeed
|
||||||
|
Steps:
|
||||||
|
1. python3 -c "
|
||||||
|
from app.downloaders.anime_sites.animesama import AnimeSamaDownloader
|
||||||
|
d = AnimeSamaDownloader()
|
||||||
|
call_count = [0]
|
||||||
|
def mock_extract(*args, **kwargs):
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] == 1: # VidMoly call
|
||||||
|
raise Exception('VidMoly failed')
|
||||||
|
elif call_count[0] == 2: # SendVid call
|
||||||
|
return ('https://sendvid.com/video.mp4', 'sendvid_video.mp4')
|
||||||
|
# Mock extraction methods
|
||||||
|
d._extract_from_vidmoly = lambda *a, **kw: mock_extract(*a, **kw)
|
||||||
|
d._extract_from_sendvid = lambda *a, **kw: mock_extract(*a, **kw)
|
||||||
|
d._test_video_url = lambda url: True
|
||||||
|
# Call fallback
|
||||||
|
video_url, filename = d.get_download_link_with_fallback('test_url|anime_page|Ep1')
|
||||||
|
print(f'Video URL: {video_url}')
|
||||||
|
print(f'Used player: {\"SendVid\" if \"sendvid\" in video_url else \"Unknown\"}')
|
||||||
|
"
|
||||||
|
Expected Result: Returns SendVid URL (VidMoly failed, SendVid succeeded)
|
||||||
|
Evidence: .sisyphus/evidence/task-2-second-works.txt
|
||||||
|
|
||||||
|
Scenario: All players fail
|
||||||
|
Tool: Bash (python3)
|
||||||
|
Preconditions: Mock all extractions to fail
|
||||||
|
Steps:
|
||||||
|
1. python3 -c "
|
||||||
|
from app.downloaders.anime_sites.animesama import AnimeSamaDownloader
|
||||||
|
d = AnimeSamaDownloader()
|
||||||
|
def mock_fail(*args, **kwargs):
|
||||||
|
raise Exception('Player failed')
|
||||||
|
# Mock all extraction methods
|
||||||
|
d._extract_from_vidmoly = mock_fail
|
||||||
|
d._extract_from_sendvid = mock_fail
|
||||||
|
d._extract_from_sibnet = mock_fail
|
||||||
|
d._extract_from_lpayer = mock_fail
|
||||||
|
# Call fallback
|
||||||
|
try:
|
||||||
|
video_url, filename = d.get_download_link_with_fallback('test_url|anime_page|Ep1')
|
||||||
|
print('ERROR: Should have raised exception')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Exception raised: {e}')
|
||||||
|
"
|
||||||
|
Expected Result: Raises exception "All video players failed"
|
||||||
|
Evidence: .sisyphus/evidence/task-2-all-fail.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Evidence to Capture**:
|
||||||
|
- [ ] Each evidence file contains video URL and logs output
|
||||||
|
- [ ] Test confirms fallback logic works correctly
|
||||||
|
|
||||||
|
**Commit**: YES
|
||||||
|
- Message: `feat(anime-sama): add player fallback logic with priority retry`
|
||||||
|
- Files: `app/downloaders/anime_sites/animesama.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [x] 3. Integrate fallback into existing get_download_link() method
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Update `get_download_link()` to use `get_download_link_with_fallback()` by default
|
||||||
|
- Maintain backward compatibility: if direct video URL detected, skip fallback
|
||||||
|
- Pass anime_page_url and episode_title from pipe-separated URL format
|
||||||
|
- Keep existing player detection and direct extraction flow for simple cases
|
||||||
|
|
||||||
|
**Must NOT do**:
|
||||||
|
- Remove existing extraction methods
|
||||||
|
- Change existing player detection logic
|
||||||
|
|
||||||
|
**Recommended Agent Profile**:
|
||||||
|
> **Category**: `quick`
|
||||||
|
- Reason: Integration task, minimal changes
|
||||||
|
- **Skills**: None needed
|
||||||
|
- **Skills Evaluated but Omitted**:
|
||||||
|
- No additional skills needed
|
||||||
|
|
||||||
|
**Parallelization**:
|
||||||
|
- **Can Run In Parallel**: NO - Depends on Task 2
|
||||||
|
- **Parallel Group**: Sequential
|
||||||
|
- **Blocks**: Task 4, 5
|
||||||
|
- **Blocked By**: Task 2
|
||||||
|
|
||||||
|
**References** (CRITICAL):
|
||||||
|
|
||||||
|
**Pattern References** (existing code to follow):
|
||||||
|
- `app/downloaders/anime_sites/animesama.py:93-120` - Current get_download_link implementation
|
||||||
|
|
||||||
|
**WHY Each Reference Matters**:
|
||||||
|
- Need to understand current logic to integrate fallback without breaking it
|
||||||
|
- Player detection and pipe URL parsing must be preserved
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
|
||||||
|
- [ ] `get_download_link()` calls `get_download_link_with_fallback()` for complex URLs
|
||||||
|
- [ ] Direct video URLs (no pipe format) skip fallback (performance)
|
||||||
|
- [ ] Pipe-separated URLs trigger fallback with anime_page_url and episode_title
|
||||||
|
- [ ] Existing Anime-Sama functionality preserved (VidMoly, SendVid, Sibnet, Lpayer)
|
||||||
|
- [ ] Backward compatible with existing download flow
|
||||||
|
|
||||||
|
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Scenario: Pipe URL triggers fallback
|
||||||
|
Tool: Bash (python3)
|
||||||
|
Preconditions: Anime-Sama downloader with fallback method
|
||||||
|
Steps:
|
||||||
|
1. python3 -c "
|
||||||
|
from app.downloaders.anime_sites.animesama import AnimeSamaDownloader
|
||||||
|
d = AnimeSamaDownloader()
|
||||||
|
# Mock to test that fallback is called
|
||||||
|
fallback_called = [False]
|
||||||
|
original_fallback = d.get_download_link_with_fallback
|
||||||
|
def mock_fallback(*args, **kwargs):
|
||||||
|
fallback_called[0] = True
|
||||||
|
return original_fallback(*args, **kw)
|
||||||
|
d.get_download_link_with_fallback = mock_fallback
|
||||||
|
# Call with pipe URL
|
||||||
|
d.get_download_link('https://vidmoly.to/vid|https://anime-sama.si/cat/naruto/s1|Episode+1')
|
||||||
|
print(f'Fallback called: {fallback_called[0]}')
|
||||||
|
"
|
||||||
|
Expected Result: Fallback method is called (True)
|
||||||
|
Evidence: .sisyphus/evidence/task-3-pipe-url.txt
|
||||||
|
|
||||||
|
Scenario: Direct video URL skips fallback
|
||||||
|
Tool: Bash (python3)
|
||||||
|
Preconditions: Anime-Sama downloader with fallback method
|
||||||
|
Steps:
|
||||||
|
1. python3 -c "
|
||||||
|
from app.downloaders.anime_sites.animesama import AnimeSamaDownloader
|
||||||
|
d = AnimeSamaDownloader()
|
||||||
|
# Mock to test that fallback is NOT called
|
||||||
|
fallback_called = [False]
|
||||||
|
def mock_fallback(*args, **kwargs):
|
||||||
|
fallback_called[0] = True
|
||||||
|
return ('https://video.mp4', 'video.mp4')
|
||||||
|
d.get_download_link_with_fallback = mock_fallback
|
||||||
|
# Call with direct URL (no pipe)
|
||||||
|
d.get_download_link('https://vidmoly.to/vid')
|
||||||
|
print(f'Fallback called: {fallback_called[0]}')
|
||||||
|
"
|
||||||
|
Expected Result: Fallback method is NOT called (False) - direct extraction used
|
||||||
|
Evidence: .sisyphus/evidence/task-3-direct-url.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Evidence to Capture**:
|
||||||
|
- [ ] Evidence files show fallback called/not-called correctly
|
||||||
|
- [ ] Integration preserves existing functionality
|
||||||
|
|
||||||
|
**Commit**: YES
|
||||||
|
- Message: `feat(anime-sama): integrate fallback into get_download_link()`
|
||||||
|
- Files: `app/downloaders/anime_sites/animesama.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [x] 4. Add unit tests for fallback logic
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Create `tests/test_anime_sama_fallback.py`
|
||||||
|
- Test 1: Fallback tries players in priority order
|
||||||
|
- Test 2: Caching mechanism stores working player
|
||||||
|
- Test 3: All players failing raises exception
|
||||||
|
- Test 4: _test_video_url() returns True/False correctly
|
||||||
|
- Use pytest with mock_httpx_client fixture
|
||||||
|
|
||||||
|
**Must NOT do**:
|
||||||
|
- Make real HTTP requests in tests (use mocks)
|
||||||
|
- Test other anime sites (Anime-Sama only)
|
||||||
|
|
||||||
|
**Recommended Agent Profile**:
|
||||||
|
> **Category**: `quick`
|
||||||
|
- Reason: Unit tests are straightforward
|
||||||
|
- **Skills**: None needed
|
||||||
|
- **Skills Evaluated but Omitted**:
|
||||||
|
- No additional skills needed
|
||||||
|
|
||||||
|
**Parallelization**:
|
||||||
|
- **Can Run In Parallel**: NO - Depends on Task 3
|
||||||
|
- **Parallel Group**: Sequential
|
||||||
|
- **Blocks**: Task 5
|
||||||
|
- **Blocked By**: Task 3
|
||||||
|
|
||||||
|
**References** (CRITICAL):
|
||||||
|
|
||||||
|
**Test References** (testing patterns to follow):
|
||||||
|
- `tests/test_downloaders.py:40-70` - Mock pattern for downloaders
|
||||||
|
- `tests/conftest.py:40-50` - Mock HTTP client fixture
|
||||||
|
|
||||||
|
**WHY Each Reference Matters**:
|
||||||
|
- Mocking patterns show how to simulate HTTP responses without network calls
|
||||||
|
- Conftest fixtures provide reusable test setup
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
|
||||||
|
- [ ] `tests/test_anime_sama_fallback.py` file created
|
||||||
|
- [ ] Test priority order: VidMoly → SendVid → Sibnet → Lpayer
|
||||||
|
- [ ] Test caching: working player reused for same anime
|
||||||
|
- [ ] Test _test_video_url: returns True/False correctly
|
||||||
|
- [ ] Test all players fail: exception raised
|
||||||
|
- [ ] All tests pass with pytest
|
||||||
|
|
||||||
|
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Scenario: Run all fallback unit tests
|
||||||
|
Tool: Bash
|
||||||
|
Preconditions: Tests implemented in test_anime_sama_fallback.py
|
||||||
|
Steps:
|
||||||
|
1. pytest tests/test_anime_sama_fallback.py -v --tb=short
|
||||||
|
Expected Result: All tests pass
|
||||||
|
Failure Indicators: Any test fails, pytest exit code non-zero
|
||||||
|
Evidence: .sisyphus/evidence/task-4-tests-run.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Evidence to Capture**:
|
||||||
|
- [ ] pytest output shows all tests passed
|
||||||
|
- [ ] Evidence file contains test summary
|
||||||
|
|
||||||
|
**Commit**: YES
|
||||||
|
- Message: `test(anime-sama): add unit tests for player fallback logic`
|
||||||
|
- Files: `tests/test_anime_sama_fallback.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [x] 5. Integration testing with real Anime-Sama URLs
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Test Frieren S2 E1 download with fallback enabled
|
||||||
|
- Verify that fallback tries multiple players if first fails
|
||||||
|
- Check logs to see which player succeeded
|
||||||
|
- Validate that downloaded video is playable
|
||||||
|
- Test with different Anime-Sama URLs to ensure general functionality
|
||||||
|
|
||||||
|
**Must NOT do**:
|
||||||
|
- Only test with Frieren (test variety)
|
||||||
|
- Modify production code during testing
|
||||||
|
|
||||||
|
**Recommended Agent Profile**:
|
||||||
|
> **Category**: `quick`
|
||||||
|
- Reason: Integration testing with real data
|
||||||
|
- **Skills**: `playwright` (may be needed for Lpayer)
|
||||||
|
- **Skills Evaluated but Omitted**:
|
||||||
|
- `git-master`: Not needed for testing
|
||||||
|
|
||||||
|
**Parallelization**:
|
||||||
|
- **Can Run In Parallel**: NO - Depends on Task 4
|
||||||
|
- **Parallel Group**: Sequential
|
||||||
|
- **Blocks**: Final Verification
|
||||||
|
- **Blocked By**: Task 4
|
||||||
|
|
||||||
|
**References** (CRITICAL):
|
||||||
|
|
||||||
|
**API/Type References** (contracts to implement against):
|
||||||
|
- `/api/anime/download` - Download endpoint
|
||||||
|
- `/api/downloads` - List downloads endpoint
|
||||||
|
|
||||||
|
**WHY Each Reference Matters**:
|
||||||
|
- Need to know how to trigger downloads and check status
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
|
||||||
|
- [ ] Frieren S2 E1 download completes successfully
|
||||||
|
- [ ] Logs show multiple players tried if first fails
|
||||||
|
- [ ] Downloaded video file is valid (not empty, correct extension)
|
||||||
|
- [ ] Fallback logic works without errors
|
||||||
|
|
||||||
|
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Scenario: Download Frieren S2 E1 with fallback
|
||||||
|
Tool: Bash (curl) + Playwright
|
||||||
|
Preconditions: Server running, fallback implemented
|
||||||
|
Steps:
|
||||||
|
1. curl -s "http://localhost:3000/api/anime/episodes?url=https://anime-sama.si/catalogue/frieren-s1/vostfr/&lang=vostfr" | python3 -m json.tool
|
||||||
|
2. Extract first episode URL
|
||||||
|
3. curl -X POST "http://localhost:3000/api/anime/download" -H "Content-Type: application/json" -d '{"url": "EPISODE_URL|PAGE_URL|Episode+1"}'
|
||||||
|
4. curl -s "http://localhost:3000/api/downloads" | python3 -m json.tool
|
||||||
|
5. Wait for download to complete (status COMPLETED)
|
||||||
|
6. ls -lh downloads/Frieren*.mp4 2>&1
|
||||||
|
Expected Result: Download completes with status COMPLETED, video file exists with > 1MB
|
||||||
|
Failure Indicators: Status FAILED, no video file, file size < 1MB
|
||||||
|
Evidence: .sisyphus/evidence/task-5-frieren-download.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Evidence to Capture**:
|
||||||
|
- [ ] Evidence file contains download status
|
||||||
|
- [ ] Video file exists and is playable
|
||||||
|
|
||||||
|
**Commit**: YES (if successful)
|
||||||
|
- Message: `test(anime-sama): verify fallback works with Frieren S2 E1`
|
||||||
|
- Files: `downloads/` (test artifacts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification Wave
|
||||||
|
|
||||||
|
- [ ] F1. **Unit Test Coverage** — `pytest`
|
||||||
|
Run pytest on anime-sama tests to ensure fallback logic is covered.
|
||||||
|
- Run: `pytest tests/test_anime_sama_fallback.py -v --cov=app.downloaders.anime_sites.animesama`
|
||||||
|
- Verify: All tests pass, coverage > 80% for new methods
|
||||||
|
Output: `Tests [N/N pass] | Coverage [%] | VERDICT`
|
||||||
|
|
||||||
|
- [ ] F2. **Real Download Test** — `curl` + `Bash`
|
||||||
|
Test actual download with Anime-Sama fallback enabled.
|
||||||
|
- Trigger: Download Frieren S2 E1 via API
|
||||||
|
- Verify: Download completes, fallback logs visible, file valid
|
||||||
|
Output: `Download [COMPLETE/FAILED] | Player [name] | File [size] | VERDICT`
|
||||||
|
|
||||||
|
- [ ] F3. **Log Analysis** — `Bash`
|
||||||
|
Check server logs for fallback behavior.
|
||||||
|
- Run: `tail -100 /tmp/uvicorn.log | grep -E "(LPAYER|fallback|player)"`
|
||||||
|
- Verify: Multiple player attempts logged when first fails
|
||||||
|
Output: `Attempts [N] | Success [True/False] | VERDICT`
|
||||||
|
|
||||||
|
- [ ] F4. **No Regressions** — `pytest`
|
||||||
|
Ensure existing Anime-Sama functionality still works.
|
||||||
|
- Run: `pytest tests/test_anime_sama.py -v -k "not fallback"`
|
||||||
|
- Verify: All existing tests pass
|
||||||
|
Output: `Tests [N/N pass] | VERDICT`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Strategy
|
||||||
|
|
||||||
|
- **1**: `feat(anime-sama): add video URL validation helper method` — `app/downloaders/anime_sites/animesama.py`
|
||||||
|
- **2**: `feat(anime-sama): add player fallback logic with priority retry` — `app/downloaders/anime_sites/animesama.py`
|
||||||
|
- **3**: `feat(anime-sama): integrate fallback into get_download_link()` — `app/downloaders/anime_sites/animesama.py`
|
||||||
|
- **4**: `test(anime-sama): add unit tests for player fallback logic` — `tests/test_anime_sama_fallback.py`
|
||||||
|
- **5**: `test(anime-sama): verify fallback works with real downloads` — `downloads/` (test artifacts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Verification Commands
|
||||||
|
```bash
|
||||||
|
# Unit tests
|
||||||
|
pytest tests/test_anime_sama_fallback.py -v
|
||||||
|
|
||||||
|
# Integration test
|
||||||
|
curl -X POST "http://localhost:3000/api/anime/download" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url": "URL|PAGE|TITLE"}'
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
tail -50 /tmp/uvicorn.log | grep fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Final Checklist
|
||||||
|
- [ ] Fallback method tries players in priority order
|
||||||
|
- [ ] Video URLs validated before returning (10KB download test)
|
||||||
|
- [ ] Working player cached per anime for performance
|
||||||
|
- [ ] All unit tests pass
|
||||||
|
- [ ] Real download test succeeds
|
||||||
|
- [ ] No regressions in existing Anime-Sama functionality
|
||||||
|
- [ ] All "Must Have" present
|
||||||
|
- [ ] All "Must NOT Have" absent
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Plan: Faire fonctionner Frieren S2 - Analyse et Solutions
|
||||||
|
|
||||||
|
## Analyse de la situation
|
||||||
|
|
||||||
|
### Fournisseurs disponibles pour Frieren S2
|
||||||
|
|
||||||
|
| Episode | Fournisseur | Status |
|
||||||
|
|---------|-------------|--------|
|
||||||
|
| 1 | Lpayer | ❌ Besoin JavaScript |
|
||||||
|
| 2 | VidMoly | ❌ Bloqué (ffmpeg) |
|
||||||
|
| 3 | Sibnet | ❌ 403 Forbidden |
|
||||||
|
| 4 | SendVid + VidMoly | ❌ Bloqué |
|
||||||
|
| 5 | Dingtez | ❌ JavaScript obfusqué |
|
||||||
|
|
||||||
|
### Causes du blocage
|
||||||
|
|
||||||
|
1. **Lpayer** : Charge les vidéos avec JavaScript React - Playwright n'arrive pas à extraire
|
||||||
|
2. **VidMoly** : Vérifie si ffmpeg est disponible, bloque les requêtes automatisées
|
||||||
|
3. **Sibnet** : Retourne 403 Forbidden pour les requêtes non-browser
|
||||||
|
4. **SendVid** : Bloque les requêtes automatisées
|
||||||
|
5. **Dingtez** : JavaScript obfusqué avec JWPlayer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solutions possibles
|
||||||
|
|
||||||
|
### Solution 1: Interface de saisie manuelle (PRIORITÉ)
|
||||||
|
- [ ] Ajouter un champ "URL vidéo directe" dans l'interface
|
||||||
|
- [ ] L'utilisateur colle l'URL qu'il a trouvée ailleurs
|
||||||
|
- [ ] Le système télécharge directement sans extraction
|
||||||
|
|
||||||
|
### Solution 2: Real-Debrid
|
||||||
|
- [ ] Intégrer l'API Real-Debrid
|
||||||
|
- [ ] Le service débride les URLs automatiquement
|
||||||
|
- [ ] Fonctionne avec tous les hébergeurs
|
||||||
|
|
||||||
|
### Solution 3: Navigateur Playwright intégré
|
||||||
|
- [ ] Utiliser Playwright pour TOUTES les extractions
|
||||||
|
- [ ] Plus lent mais plus fiable
|
||||||
|
- [ ] Nécessite plus de ressources
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommandation
|
||||||
|
|
||||||
|
Commencer par **Solution 1** (la plus simple et fiable) puis **Solution 2** (Real-Debrid).
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
# Harmonize Watchlist Design - Align with Main Page
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
> **Quick Summary**: Harmonize the visual design of watchlist page to match /web page while keeping watchlist as separate autonomous page.
|
||||||
|
|
||||||
|
> **Deliverables**:
|
||||||
|
> - Update watchlist.html to use same background gradient and styling as /web
|
||||||
|
> - Unify header design (colors, layout, icons)
|
||||||
|
> - Align button styles to match /web patterns
|
||||||
|
> - Maintain watchlist functionality (no breaking changes)
|
||||||
|
|
||||||
|
> **Estimated Effort**: Medium
|
||||||
|
> **Parallel Execution**: NO - single task
|
||||||
|
> **Critical Path**: CSS updates → styling verification → commit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Original Request Summary
|
||||||
|
User identified that watchlist (/watchlist) page has a completely different design from the main page (/web), creating UX inconsistency:
|
||||||
|
- Watchlist has dark violet gradient background, /web has cleaner light gradient
|
||||||
|
- Watchlist has custom header "📋 Ma Watchlist", /web has unified navigation tabs
|
||||||
|
- Watchlist has its own navigation button, /web has tab-based navigation
|
||||||
|
- Different color schemes, layouts, and styling patterns
|
||||||
|
|
||||||
|
### User's Decision
|
||||||
|
User chose **Option 2: Harmonize watchlist design** - adapt watchlist visual design to match /web styling while keeping it as a separate page.
|
||||||
|
|
||||||
|
### Key Findings
|
||||||
|
- /web uses light gradient background (135deg, #1e1e2e 0%, #2d1b69 0%, #1e1e2e 100%)
|
||||||
|
- Watchlist currently uses dark violet gradient background
|
||||||
|
- /web has tab-based navigation (Accueil, Anime, Série, Fournisseurs, Watchlist)
|
||||||
|
- Watchlist has standalone page design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Work Objectives
|
||||||
|
|
||||||
|
### Core Objective
|
||||||
|
Harmonize the visual design of templates/watchlist.html to match the styling and patterns of templates/index.html (/web), creating visual consistency across the application.
|
||||||
|
|
||||||
|
### Concrete Deliverables
|
||||||
|
- Updated `templates/watchlist.html` background to match /web
|
||||||
|
- Unified header design colors and layout
|
||||||
|
- Aligned button styles (btn-primary, btn-secondary)
|
||||||
|
- Consistent typography and spacing
|
||||||
|
- Maintained all watchlist functionality (scheduler, stats, search, add/remove items)
|
||||||
|
|
||||||
|
### Definition of Done
|
||||||
|
- [ ] Watchlist background gradient matches /web
|
||||||
|
- [ ] Header text color and styling matches /web
|
||||||
|
- [ ] Button styles (btn-primary, btn-secondary) match /web
|
||||||
|
- [ ] Overall visual appearance is consistent with /web
|
||||||
|
- [ ] All watchlist features still work (no breaking changes)
|
||||||
|
|
||||||
|
### Must Have
|
||||||
|
- Harmonize visual design with /web
|
||||||
|
- Match background gradient colors
|
||||||
|
- Align header styling (fonts, colors, icons)
|
||||||
|
- Unify button class styles
|
||||||
|
- Maintain all existing watchlist functionality
|
||||||
|
|
||||||
|
### Must NOT Have (Guardrails)
|
||||||
|
- **DO NOT remove watchlist functionality** - scheduler, stats, notifications must still work
|
||||||
|
- **DO NOT change /web design** - only adapt watchlist to match
|
||||||
|
- **DO NOT break existing URL routes** - /watchlist and /web must both work
|
||||||
|
- **DO NOT modify JavaScript files** - only HTML/CSS changes
|
||||||
|
- **DO NOT add new features** - this is visual harmonization only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Strategy
|
||||||
|
|
||||||
|
### Test Decision
|
||||||
|
- **Infrastructure exists**: YES (uvicorn server)
|
||||||
|
- **Automated tests**: NO (visual changes, manual QA)
|
||||||
|
- **Framework**: None - manual browser verification
|
||||||
|
- **Rationale**: This is visual CSS/template change, requires manual browser verification
|
||||||
|
|
||||||
|
### QA Policy
|
||||||
|
Visual verification required for design changes:
|
||||||
|
- Use dev-browser (playwright) to load both pages
|
||||||
|
- Compare visual appearance side-by-side
|
||||||
|
- Verify no functionality broken
|
||||||
|
- Evidence saved to `.sisyphus/evidence/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Strategy
|
||||||
|
|
||||||
|
### Sequential Execution
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1: Update Watchlist Background Gradient
|
||||||
|
- Modify templates/watchlist.html
|
||||||
|
- Replace dark violet gradient with /web's light gradient
|
||||||
|
- Verify page loads and looks correct
|
||||||
|
|
||||||
|
Task 2: Harmonize Header Design
|
||||||
|
- Update header colors, fonts, layout
|
||||||
|
- Match /web navigation header styling
|
||||||
|
- Ensure text colors are consistent
|
||||||
|
- [ ] 2. Harmonize Header Design
|
||||||
|
Task 3: Align Button Styles
|
||||||
|
- Update button classes to use same styles as /web
|
||||||
|
- Verify hover states and interactions
|
||||||
|
- Ensure responsive behavior matches
|
||||||
|
|
||||||
|
- [ ] 4. Final Verification
|
||||||
|
- Load both /web and /watchlist in browser
|
||||||
|
- Take screenshots for comparison
|
||||||
|
- Verify all functionality works
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent Dispatch Summary
|
||||||
|
|
||||||
|
- **1**: **1** — T1 (visual-engineering)
|
||||||
|
- **2**: **1** — T2 (visual-engineering)
|
||||||
|
- **3**: **1** — T3 (visual-engineering)
|
||||||
|
- **4**: **1** — T4 (visual-engineering)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODOs
|
||||||
|
|
||||||
|
- [ ] 1. Update Watchlist Background Gradient
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Read `templates/watchlist.html` to find current background styling
|
||||||
|
- Read `templates/index.html` to get the light gradient background
|
||||||
|
- Replace watchlist's dark violet gradient: `background: linear-gradient(135deg, #1e1e2e 0%, #2d1b69 0%, #1e1e2e 100%)`
|
||||||
|
- With /web's light gradient: Need to check index.html for exact colors
|
||||||
|
|
||||||
|
**Must NOT do**:
|
||||||
|
- Remove watchlist functionality (scheduler, stats, search)
|
||||||
|
- Change the structure of the page
|
||||||
|
- Modify JavaScript files
|
||||||
|
|
||||||
|
**Recommended Agent Profile**:
|
||||||
|
- **Category**: `visual-engineering`
|
||||||
|
- Reason: CSS styling update for visual consistency
|
||||||
|
- **Skills**: []
|
||||||
|
- No special skills needed - CSS gradient change
|
||||||
|
|
||||||
|
**Parallelization**:
|
||||||
|
- **Can Run In Parallel**: NO
|
||||||
|
- **Parallel Group**: Single task
|
||||||
|
- **Blocks**: Task 2
|
||||||
|
- **Blocked By**: None (can start immediately)
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
|
||||||
|
**Pattern References**:
|
||||||
|
- `templates/index.html` - Reference for correct background gradient
|
||||||
|
- `templates/watchlist.html` - File to modify
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Watchlist uses light gradient like /web
|
||||||
|
grep -c "linear-gradient(135deg" templates/watchlist.html
|
||||||
|
Expected: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Scenario: Watchlist page uses light gradient background
|
||||||
|
Tool: dev-browser (playwright)
|
||||||
|
Preconditions: Server running on port 3000
|
||||||
|
Steps:
|
||||||
|
1. Navigate to http://localhost:3000/watchlist
|
||||||
|
2. Wait for page to load (timeout 10s)
|
||||||
|
3. Take screenshot of page background
|
||||||
|
4. Navigate to http://localhost:3000/web
|
||||||
|
5. Take screenshot for comparison
|
||||||
|
Expected Result: Watchlist background matches /web's light gradient
|
||||||
|
Failure Indicators: Background still dark violet, colors don't match
|
||||||
|
Evidence: .sisyphus/evidence/task-1-background-gradient.png
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit**: NO
|
||||||
|
- Groups with Task 2, 3
|
||||||
|
|
||||||
|
- [ ] 2. Harmonize Header Design
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Read `templates/watchlist.html` to check current header styling
|
||||||
|
- Read `templates/index.html` to get header reference
|
||||||
|
- Update watchlist header colors to match /web's color scheme
|
||||||
|
- Update fonts to match /web typography
|
||||||
|
- Ensure header layout and spacing match /web
|
||||||
|
- Keep "📋 Ma Watchlist" title but update colors
|
||||||
|
|
||||||
|
**Must NOT do**:
|
||||||
|
- Remove header functionality
|
||||||
|
- Change header text/title
|
||||||
|
- Remove the "Retour à l'accueil" button added earlier
|
||||||
|
|
||||||
|
**Recommended Agent Profile**:
|
||||||
|
- **Category**: `visual-engineering`
|
||||||
|
- Reason: Header styling harmonization
|
||||||
|
- **Skills**: []
|
||||||
|
- CSS styling task
|
||||||
|
|
||||||
|
**Parallelization**:
|
||||||
|
- **Can Run In Parallel**: NO
|
||||||
|
- **Parallel Group**: Single task
|
||||||
|
- **Blocks**: Task 3
|
||||||
|
- **Blocked By**: Task 1 (background must be updated first)
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
|
||||||
|
**Pattern References**:
|
||||||
|
- `templates/index.html` - Reference for header styling
|
||||||
|
- `templates/watchlist.html` - File to modify
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Header uses same colors as /web
|
||||||
|
# Verify no dark violet colors remain
|
||||||
|
```
|
||||||
|
|
||||||
|
**QA Scenarios (MANDATORY):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Scenario: Header design matches /web
|
||||||
|
Tool: dev-browser (playwright)
|
||||||
|
Preconditions: Tasks 1 complete, server running
|
||||||
|
Steps:
|
||||||
|
1. Navigate to http://localhost:3000/watchlist
|
||||||
|
2. Take screenshot of header section
|
||||||
|
3. Navigate to http://localhost:3000/web
|
||||||
|
4. Take screenshot of navigation header
|
||||||
|
5. Compare screenshots side-by-side
|
||||||
|
Expected Result: Watchlist header colors, fonts, layout match /web
|
||||||
|
Failure Indicators: Different colors, fonts mismatched, layout differences
|
||||||
|
Evidence: .sisyphus/evidence/task-2-header-harmonization.png
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit**: NO
|
||||||
|
- Groups with Task 3
|
||||||
|
|
||||||
|
- [ ] 3. Align Button Styles
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Read `templates/watchlist.html` to identify all button elements
|
||||||
|
- Read `templates/index.html` to get button class references
|
||||||
|
- Ensure all buttons use consistent classes (btn-primary, btn-secondary)
|
||||||
|
- Verify hover states and interactions work correctly
|
||||||
|
- Make sure "Retour à l'accueil" button style is aligned
|
||||||
|
|
||||||
|
**Must NOT do**:
|
||||||
|
- Change button functionality or behavior
|
||||||
|
- Remove any buttons
|
||||||
|
- Modify JavaScript event handlers
|
||||||
|
|
||||||
|
**Recommended Agent Profile**:
|
||||||
|
- **Category**: `visual-engineering`
|
||||||
|
- Reason: Button styling alignment
|
||||||
|
- **Skills**: []
|
||||||
|
- CSS class updates
|
||||||
|
|
||||||
|
**Parallelization**:
|
||||||
|
- **Can Run In Parallel**: NO
|
||||||
|
- **Parallel Group**: Single task
|
||||||
|
- **Blocks**: Task 4
|
||||||
|
- **Blocked By**: Task 2 (header must be updated first)
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
|
||||||
|
**Pattern References**:
|
||||||
|
- `templates/index.html` - Reference for button styles
|
||||||
|
- `templates/watchlist.html` - File to modify
|
||||||
|
- `static/css/style.css` - Button class definitions (if exists)
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All buttons use btn-primary or btn-secondary classes
|
||||||
|
grep -o "class=\"btn-" templates/watchlist.html | sort | uniq
|
||||||
|
Expected: btn-primary, btn-secondary (or similar consistent classes)
|
||||||
|
```
|
||||||
|
|
||||||
|
**QA Scenarios (MANDATORY):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Scenario: Button styles are consistent with /web
|
||||||
|
Tool: dev-browser (playwright)
|
||||||
|
Preconditions: Tasks 1, 2 complete
|
||||||
|
Steps:
|
||||||
|
1. Navigate to http://localhost:3000/watchlist
|
||||||
|
- [ ] 3. Align Button Styles
|
||||||
|
3. Click buttons, verify interactions work
|
||||||
|
4. Check no console errors
|
||||||
|
Expected Result: All buttons have consistent styling with /web, hover states work
|
||||||
|
Failure Indicators: Different button styles, broken interactions, console errors
|
||||||
|
Evidence: .sisyphus/evidence/task-3-button-alignment.png
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit**: YES
|
||||||
|
- Message: `style(ui): Harmonize watchlist design to match /web`
|
||||||
|
- Files: `templates/watchlist.html`
|
||||||
|
|
||||||
|
- [ ] 4. Final Verification
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Start server if not running: `uvicorn main:app --host 0.0.0.0 --port 3000`
|
||||||
|
- Navigate to `/web` and verify page works
|
||||||
|
- Navigate to `/watchlist` and verify page works
|
||||||
|
- Take comparison screenshots
|
||||||
|
- Verify navigation works both ways
|
||||||
|
- Check browser console for errors
|
||||||
|
- Verify watchlist features (search, scheduler, stats, add/remove items) still work
|
||||||
|
|
||||||
|
**Must NOT do**:
|
||||||
|
- Make any code changes
|
||||||
|
- Modify functionality
|
||||||
|
|
||||||
|
**Recommended Agent Profile**:
|
||||||
|
- **Category**: `unspecified-high`
|
||||||
|
- Reason: Final integration verification
|
||||||
|
- **Skills**: [`dev-browser`]
|
||||||
|
- dev-browser: Use Playwright for browser automation and screenshots
|
||||||
|
|
||||||
|
**Parallelization**:
|
||||||
|
- **Can Run In Parallel**: NO
|
||||||
|
- **Parallel Group**: Final task
|
||||||
|
- **Blocks**: None
|
||||||
|
- **Blocked By**: Tasks 1, 2, 3 (all tasks must complete)
|
||||||
|
|
||||||
|
**References**:
|
||||||
|
|
||||||
|
**Pattern References**:
|
||||||
|
- `templates/index.html` - Reference for expected design
|
||||||
|
- `templates/watchlist.html` - File being modified
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Both pages work
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/web
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/watchlist
|
||||||
|
Expected: 200 for both
|
||||||
|
```
|
||||||
|
|
||||||
|
**QA Scenarios (MANDATORY):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Scenario: Visual design is harmonized between /web and /watchlist
|
||||||
|
Tool: dev-browser (playwright)
|
||||||
|
Preconditions: All styling tasks complete, server running
|
||||||
|
Steps:
|
||||||
|
1. Navigate to http://localhost:3000/web
|
||||||
|
2. Take full page screenshot
|
||||||
|
3. Navigate to http://localhost:3000/watchlist
|
||||||
|
4. Take full page screenshot
|
||||||
|
5. Compare side-by-side
|
||||||
|
6. Verify backgrounds match
|
||||||
|
7. Verify header styles match
|
||||||
|
8. Verify button styles match
|
||||||
|
Expected Result: Visual design is consistent between both pages
|
||||||
|
Failure Indicators: Color mismatch, style differences, broken features
|
||||||
|
Evidence: .sisyphus/evidence/task-4-verification-screenshot.png
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit**: NO
|
||||||
|
- This is verification only, no code changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification Wave (MANDATORY — after ALL implementation tasks)
|
||||||
|
|
||||||
|
> 3 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.
|
||||||
|
|
||||||
|
- [ ] F1. **Visual Design Review** — `visual-engineering`
|
||||||
|
Compare watchlist and /web designs side-by-side. Verify colors, gradients, typography, spacing, and layout are harmonized. Check for any visual inconsistencies.
|
||||||
|
|
||||||
|
Output: `Background [MATCH/MISMATCH] | Header [MATCH/MISMATCH] | Buttons [MATCH/MISMATCH] | VERDICT: APPROVE/REJECT`
|
||||||
|
|
||||||
|
- [ ] F2. **Functionality Verification** — `unspecified-high` (+ `dev-browser` skill)
|
||||||
|
Navigate to /watchlist and verify all features work: search, scheduler controls, stats display, add/remove items, navigation. Check browser console for errors.
|
||||||
|
|
||||||
|
Output: `Features [N/N working] | Console Errors [0/N] | VERDICT: APPROVE/REJECT`
|
||||||
|
|
||||||
|
- [ ] F3. **Code Quality Check** — `quick`
|
||||||
|
Check for CSS syntax errors, invalid colors, or broken HTML structure.
|
||||||
|
|
||||||
|
Output: `CSS [VALID/INVALID] | HTML [VALID/INVALID] | VERDICT: APPROVE/REJECT`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Strategy
|
||||||
|
|
||||||
|
- **1**: `style(ui): Harmonize watchlist design to match /web`
|
||||||
|
- Files: `templates/watchlist.html`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Verification Commands
|
||||||
|
```bash
|
||||||
|
# Watchlist uses light gradient
|
||||||
|
grep -c "linear-gradient(135deg" templates/watchlist.html
|
||||||
|
# Expected: 1
|
||||||
|
|
||||||
|
# Both pages work
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/web
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/watchlist
|
||||||
|
# Expected: 200 for both
|
||||||
|
```
|
||||||
|
|
||||||
|
### Final Checklist
|
||||||
|
- [ ] Watchlist background matches /web
|
||||||
|
- [ ] Header design harmonized with /web
|
||||||
|
- [ ] Button styles aligned with /web
|
||||||
|
- [ ] All watchlist features still work
|
||||||
|
- [ ] Both pages load without errors
|
||||||
|
- [ ] Visual design is consistent
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
# Plan : Refonte du Système Watchlist
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
> **Objectif** : Refaire le système de watchlist avec auto-téléchargement, notifications et stockage SQLite
|
||||||
|
>
|
||||||
|
> **Deliverables** :
|
||||||
|
> - Base de données SQLite pour la watchlist
|
||||||
|
> - API REST pour gérer les animes suivis
|
||||||
|
> - Système d'auto-téléchargement (vérification automatique des nouveaux épisodes)
|
||||||
|
> - Système de notifications (in-app)
|
||||||
|
> - Interface frontend (page séparée, même style que le reste)
|
||||||
|
>
|
||||||
|
> **Effort** : XL
|
||||||
|
> **Exécution** : En waves parallèles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
### Système Actuel
|
||||||
|
- Stockage JSON (`config/watchlist.json`)
|
||||||
|
- Pas de SQLite
|
||||||
|
- Auto-download basique via scheduler
|
||||||
|
- Pas de système de notifications
|
||||||
|
- Interface intégrée à la page principale
|
||||||
|
|
||||||
|
### Besoins Utilisateur
|
||||||
|
- Auto-téléchargement des nouveaux épisodes ✅
|
||||||
|
- Notifications quand un nouvel épisode est dispo ✅
|
||||||
|
- Stockage SQLite ✅
|
||||||
|
- Même style que le reste du site ✅
|
||||||
|
- Page séparée ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Work Objectives
|
||||||
|
|
||||||
|
### Objectif Principal
|
||||||
|
Créer un système de watchlist complet permettant de :
|
||||||
|
1. Suivre des animes (ajout via recherche)
|
||||||
|
2. Détecter automatiquement les nouveaux épisodes
|
||||||
|
3. Télécharger automatiquement les nouveaux épisodes
|
||||||
|
4. Notifier l'utilisateur quand un nouvel épisode est disponible
|
||||||
|
|
||||||
|
### Deliverables Concrets
|
||||||
|
- [ ] Base de données SQLite (`config/watchlist.db`)
|
||||||
|
- [ ] Modèles Pydantic pour la watchlist
|
||||||
|
- [ ] API endpoints (CRUD + actions)
|
||||||
|
- [ ] Service d'auto-check (scheduler)
|
||||||
|
- [ ] Service de notifications
|
||||||
|
- [ ] Page frontend dédiée
|
||||||
|
- [ ] Intégration avec le système de download existant
|
||||||
|
|
||||||
|
### Définition de Terminé
|
||||||
|
- [ ] Un anime peut être ajouté à la watchlist
|
||||||
|
- [ ] La watchlist affiche tous les animes suivis
|
||||||
|
- [ ] Les épisodes peuvent être téléchargés manuellement
|
||||||
|
- [ ] Le scheduler vérifie automatiquement les nouveaux épisodes
|
||||||
|
- [ ] Les nouveaux épisodes sont téléchargés automatiquement
|
||||||
|
- [ ] Une notification apparaît quand un nouvel épisode est dispo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Structure des Fichiers
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── watchlist/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── models.py # Modèles Pydantic
|
||||||
|
│ ├── database.py # Connexion SQLite
|
||||||
|
│ ├── service.py # Logique métier
|
||||||
|
│ ├── scheduler.py # Auto-check
|
||||||
|
│ └── notifications.py # Notifications
|
||||||
|
├── routes/
|
||||||
|
│ └── watchlist.py # API endpoints
|
||||||
|
static/
|
||||||
|
└── js/
|
||||||
|
└── watchlist/ # Frontend
|
||||||
|
├── index.js
|
||||||
|
├── components/
|
||||||
|
└── style.css
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schéma Base de Données (SQLite)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Table principale : watchlist items
|
||||||
|
CREATE TABLE watchlist_items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
anime_title TEXT NOT NULL,
|
||||||
|
anime_url TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
lang TEXT DEFAULT 'vostfr',
|
||||||
|
poster_image TEXT,
|
||||||
|
cover_image TEXT,
|
||||||
|
synopsis TEXT,
|
||||||
|
genres TEXT, -- JSON array
|
||||||
|
|
||||||
|
-- Tracking
|
||||||
|
status TEXT DEFAULT 'active', -- active, paused, completed
|
||||||
|
auto_download INTEGER DEFAULT 1,
|
||||||
|
quality_preference TEXT DEFAULT 'auto',
|
||||||
|
last_episode_downloaded INTEGER DEFAULT 0,
|
||||||
|
total_episodes INTEGER,
|
||||||
|
last_checked_at TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Table : Episodes téléchargés
|
||||||
|
CREATE TABLE downloaded_episodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
watchlist_item_id TEXT NOT NULL,
|
||||||
|
episode_number INTEGER NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
file_path TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
downloaded_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (watchlist_item_id) REFERENCES watchlist_items(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Table : Notifications
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
watchlist_item_id TEXT,
|
||||||
|
type TEXT NOT NULL, -- new_episode, download_complete, error
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
read INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (watchlist_item_id) REFERENCES watchlist_items(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Table : Settings
|
||||||
|
CREATE TABLE watchlist_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
check_interval_hours INTEGER DEFAULT 6,
|
||||||
|
auto_download_enabled INTEGER DEFAULT 1,
|
||||||
|
max_concurrent_downloads INTEGER DEFAULT 2,
|
||||||
|
notifications_enabled INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Strategy
|
||||||
|
|
||||||
|
### Wave 1 (Fondations)
|
||||||
|
```
|
||||||
|
Tâches :
|
||||||
|
├── 1. Créer structure du module watchlist/
|
||||||
|
├── 2. Créer database.py (connexion SQLite, migrations)
|
||||||
|
├── 3. Créer models.py (Pydantic models)
|
||||||
|
├── 4. Créer service.py (CRUD operations)
|
||||||
|
└── 5. Mettre à jour models/__init__.py
|
||||||
|
|
||||||
|
Dépendances :Aucune (start immediate)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wave 2 (API + Scheduler)
|
||||||
|
```
|
||||||
|
Tâches (dépendent de Wave 1) :
|
||||||
|
├── 6. Créer routes/watchlist.py (API endpoints)
|
||||||
|
├── 7. Créer scheduler.py (auto-check)
|
||||||
|
├── 8. Intégrer scheduler dans main.py
|
||||||
|
└── 9. Créer notifications.py
|
||||||
|
|
||||||
|
Bloqué par : 1-5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wave 3 (Frontend)
|
||||||
|
```
|
||||||
|
Tâches (dépendent de Wave 2) :
|
||||||
|
├── 10. Créer page HTML watchlist.html
|
||||||
|
├── 11. Créer watchlist-ui.js (logique)
|
||||||
|
├── 12. Ajouter CSS pour la page
|
||||||
|
└── 13. Ajouter routes pour servir la page
|
||||||
|
|
||||||
|
Bloqué par : 6-9
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wave 4 (Intégration + Tests)
|
||||||
|
```
|
||||||
|
Tâches :
|
||||||
|
├── 14. Tester l'ajout d'un anime
|
||||||
|
├── 15. Tester le téléchargement manuel
|
||||||
|
├── 16. Tester l'auto-download
|
||||||
|
├── 17. Tester les notifications
|
||||||
|
└── 18. Nettoyer l'ancien code
|
||||||
|
|
||||||
|
Bloqué par : 10-13
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODOs
|
||||||
|
|
||||||
|
- [ ] 1. **Créer la structure du module watchlist/**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Créer le répertoire `app/watchlist/`
|
||||||
|
- Créer `__init__.py` avec exports
|
||||||
|
|
||||||
|
**Pas faire** :
|
||||||
|
- Toucher aux autres modules
|
||||||
|
|
||||||
|
**Agent recommandé** : `quick`
|
||||||
|
|
||||||
|
**QA Scenarios** :
|
||||||
|
```
|
||||||
|
Scenario: Le répertoire existe
|
||||||
|
Tool: Bash
|
||||||
|
Command: ls -la app/watchlist/
|
||||||
|
Expected: Le répertoire existe avec __init__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 2. **Créer database.py (connexion SQLite)**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Créer `app/watchlist/database.py`
|
||||||
|
- Implémenter connexion SQLite avec `sqlite3`
|
||||||
|
- Implémenter fonctions : `init_db()`, `get_connection()`, `migrate()`
|
||||||
|
- Créer les tables définies dans le schéma
|
||||||
|
|
||||||
|
**Pas faire** :
|
||||||
|
- Toucher aux autres fichiers
|
||||||
|
|
||||||
|
**Agent recommandé** : `quick`
|
||||||
|
|
||||||
|
**QA Scenarios** :
|
||||||
|
```
|
||||||
|
Scenario: La base de données est créée
|
||||||
|
Tool: Bash
|
||||||
|
Command: python3 -c "from app.watchlist.database import init_db; init_db(); import os; print(os.path.exists('config/watchlist.db'))"
|
||||||
|
Expected: True
|
||||||
|
|
||||||
|
Scenario: Les tables existent
|
||||||
|
Tool: Bash
|
||||||
|
Command: sqlite3 config/watchlist.db ".tables"
|
||||||
|
Expected: watchlist_items downloaded_episodes notifications watchlist_settings
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 3. **Créer models.py**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Créer `app/watchlist/models.py`
|
||||||
|
- Définir les modèles Pydantic :
|
||||||
|
- WatchlistItem, WatchlistItemCreate, WatchlistItemUpdate
|
||||||
|
- DownloadedEpisode
|
||||||
|
- Notification, NotificationCreate
|
||||||
|
- WatchlistSettings
|
||||||
|
- Utiliser les types existants de `app/models/`
|
||||||
|
|
||||||
|
**Pas faire** :
|
||||||
|
- Dupliquer les types existants
|
||||||
|
|
||||||
|
**Agent recommandé** : `quick`
|
||||||
|
|
||||||
|
**QA Scenarios** :
|
||||||
|
```
|
||||||
|
Scenario: Les modèles peuvent être importés
|
||||||
|
Tool: Bash
|
||||||
|
Command: python3 -c "from app.watchlist.models import WatchlistItem, Notification; print('OK')"
|
||||||
|
Expected: OK (no error)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 4. **Créer service.py**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Créer `app/watchlist/service.py`
|
||||||
|
- Implémenter `WatchlistService` avec :
|
||||||
|
- `add_item()`, `get_items()`, `get_item()`, `update_item()`, `delete_item()`
|
||||||
|
- `mark_episode_downloaded()`, `get_downloaded_episodes()`
|
||||||
|
- `create_notification()`, `get_notifications()`, `mark_notification_read()`
|
||||||
|
- `get_settings()`, `update_settings()`
|
||||||
|
- `get_items_due_for_check()`
|
||||||
|
- Utiliser SQLite directement (pas d'ORM)
|
||||||
|
|
||||||
|
**Pas faire** :
|
||||||
|
- Toucher au frontend
|
||||||
|
|
||||||
|
**Agent recommandé** : `unspecified-high`
|
||||||
|
|
||||||
|
**QA Scenarios** :
|
||||||
|
```
|
||||||
|
Scenario: Ajouter un item à la watchlist
|
||||||
|
Tool: Bash
|
||||||
|
Command: python3 -c "
|
||||||
|
from app.watchlist.service import WatchlistService
|
||||||
|
svc = WatchlistService()
|
||||||
|
item = svc.add_item(user_id='test', anime_title='Test Anime', anime_url='https://example.com', provider_id='anime-sama')
|
||||||
|
print(f'Created: {item.id}')
|
||||||
|
"
|
||||||
|
Expected: Un UUID est retourné
|
||||||
|
|
||||||
|
Scenario: Récupérer les items
|
||||||
|
Tool: Bash
|
||||||
|
Command: python3 -c "
|
||||||
|
from app.watchlist.service import WatchlistService
|
||||||
|
svc = WatchlistService()
|
||||||
|
items = svc.get_items()
|
||||||
|
print(f'Count: {len(items)}')
|
||||||
|
"
|
||||||
|
Expected: Count: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 5. **Mettre à jour models/__init__.py**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Ajouter export des nouveaux modèles si besoin
|
||||||
|
|
||||||
|
**Agent recommandé** : `quick`
|
||||||
|
|
||||||
|
- [ ] 6. **Créer routes/watchlist.py**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Créer `app/routes/watchlist.py`
|
||||||
|
- Définir les endpoints :
|
||||||
|
- `GET /api/watchlist` - Liste des items
|
||||||
|
- `POST /api/watchlist` - Ajouter un item
|
||||||
|
- `GET /api/watchlist/{id}` - Détail d'un item
|
||||||
|
- `PUT /api/watchlist/{id}` - Modifier un item
|
||||||
|
- `DELETE /api/watchlist/{id}` - Supprimer un item
|
||||||
|
- `POST /api/watchlist/{id}/download/{episode}` - Télécharger un épisode
|
||||||
|
- `GET /api/watchlist/{id}/episodes` - Épisodes téléchargés
|
||||||
|
- `GET /api/watchlist/notifications` - Liste des notifications
|
||||||
|
- `PUT /api/watchlist/notifications/{id}/read` - Marquer comme lu
|
||||||
|
- `GET /api/watchlist/settings` - Settings
|
||||||
|
- `PUT /api/watchlist/settings` - Mettre à jour settings
|
||||||
|
- Ajouter auth (Bearer token)
|
||||||
|
- Intégrer avec `download_manager` pour les téléchargements
|
||||||
|
|
||||||
|
**Agent recommandé** : `unspecified-high`
|
||||||
|
|
||||||
|
**QA Scenarios** :
|
||||||
|
```
|
||||||
|
Scenario: L'API répond
|
||||||
|
Tool: Bash
|
||||||
|
Command: curl -s http://127.0.0.1:3000/api/watchlist
|
||||||
|
Expected: {"items": [...], "count": N}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 7. **Créer scheduler.py**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Créer `app/watchlist/scheduler.py`
|
||||||
|
- Implémenter `WatchlistScheduler` :
|
||||||
|
- `start()`, `stop()`
|
||||||
|
- `_check_loop()` - Boucle principale
|
||||||
|
- `check_item(item)` - Vérifier un anime
|
||||||
|
- `download_new_episodes(item, new_episodes)` - Télécharger
|
||||||
|
- Utiliser `APScheduler` (déjà dans requirements)
|
||||||
|
- Intervalle configurable (défaut: 6h)
|
||||||
|
|
||||||
|
**Agent recommandé** : `unspecified-high`
|
||||||
|
|
||||||
|
- [ ] 8. **Intégrer scheduler dans main.py**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Importer et initialiser le scheduler
|
||||||
|
- Ajouter au startup event
|
||||||
|
- Ajouter au shutdown event
|
||||||
|
|
||||||
|
**Agent recommandé** : `quick`
|
||||||
|
|
||||||
|
- [ ] 9. **Créer notifications.py**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Créer `app/watchlist/notifications.py`
|
||||||
|
- Implémenter `NotificationService`
|
||||||
|
- Types de notifications :
|
||||||
|
- `new_episode` - Nouvel épisode détecté
|
||||||
|
- `download_started` - Téléchargement commencé
|
||||||
|
- `download_complete` - Téléchargement terminé
|
||||||
|
- `download_error` - Erreur de téléchargement
|
||||||
|
- Stocker dans SQLite
|
||||||
|
- Retourner via API pour affichage
|
||||||
|
|
||||||
|
**Agent recommandé** : `quick`
|
||||||
|
|
||||||
|
- [ ] 10. **Créer page HTML watchlist.html**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Créer `templates/watchlist.html`
|
||||||
|
- Même structure que `index.html`
|
||||||
|
- Sections :
|
||||||
|
- Header avec stats
|
||||||
|
- Liste des animes (cards)
|
||||||
|
- Zone de notifications
|
||||||
|
- Modal pour les détails
|
||||||
|
|
||||||
|
**Agent recommandé** : `visual-engineering`
|
||||||
|
|
||||||
|
**QA Scenarios** :
|
||||||
|
```
|
||||||
|
Scenario: La page se charge
|
||||||
|
Tool: playwright
|
||||||
|
Navigate: http://127.0.0.1:3000/watchlist
|
||||||
|
Expected: Titre "Ma Watchlist" affiché
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 11. **Créer watchlist-ui.js**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Créer `static/js/watchlist/main.js`
|
||||||
|
- Fonctions :
|
||||||
|
- `loadWatchlist()` - Charger la liste
|
||||||
|
- `renderWatchlist(items)` - Afficher les cards
|
||||||
|
- `addAnime(animeData)` - Ajouter un anime
|
||||||
|
- `removeAnime(id)` - Retirer
|
||||||
|
- `downloadEpisode(itemId, episode)` - Télécharger
|
||||||
|
- `loadNotifications()` - Charger les notifs
|
||||||
|
- `renderNotifications(notifs)` - Afficher
|
||||||
|
- `markAsRead(id)` - Marquer lu
|
||||||
|
- Appels API vers les endpoints créés
|
||||||
|
|
||||||
|
**Agent recommandé** : `visual-engineering`
|
||||||
|
|
||||||
|
- [ ] 12. **Ajouter CSS**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Créer `static/css/watchlist.css`
|
||||||
|
- Style cohérent avec `style.css` existant
|
||||||
|
- Cards, badges, buttons, notifications
|
||||||
|
|
||||||
|
**Agent recommandé** : `visual-engineering`
|
||||||
|
|
||||||
|
- [ ] 13. **Ajouter routes pour servir la page**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Ajouter route `GET /watchlist` dans main.py
|
||||||
|
- Servir le template
|
||||||
|
|
||||||
|
**Agent recommandé** : `quick`
|
||||||
|
|
||||||
|
- [ ] 14-17. **Tests d'intégration**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Tester le flux complet :
|
||||||
|
1. Ajouter un anime via API
|
||||||
|
2. Voir dans la liste
|
||||||
|
3. Télécharger un épisode manuellement
|
||||||
|
4. Recevoir une notification
|
||||||
|
- Tester l'auto-download (simuler un nouvel épisode)
|
||||||
|
|
||||||
|
**Agent recommandé** : `unspecified-high`
|
||||||
|
|
||||||
|
- [ ] 18. **Nettoyer l'ancien code**
|
||||||
|
|
||||||
|
**Quoi faire** :
|
||||||
|
- Supprimer `app/watchlist.py` (l'ancien)
|
||||||
|
- Supprimer les fichiers JSON `config/watchlist*.json`
|
||||||
|
- Mettre à jour les imports
|
||||||
|
|
||||||
|
**Agent recommandé** : `quick`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stratégie de Vérification
|
||||||
|
|
||||||
|
### Test Manual (Agent QA)
|
||||||
|
|
||||||
|
**Scenario: Ajout d'un anime**
|
||||||
|
```
|
||||||
|
1. Ouvrir /watchlist
|
||||||
|
2. Cliquer "Ajouter un anime"
|
||||||
|
3. Rechercher "Frieren"
|
||||||
|
4. Sélectionner un résultat
|
||||||
|
5. Cliquer "Suivre"
|
||||||
|
Expected: L'anime apparaît dans la liste
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario: Téléchargement manuel**
|
||||||
|
```
|
||||||
|
1. Dans la watchlist, cliquer sur un anime
|
||||||
|
2. Voir la liste des épisodes
|
||||||
|
3. Cliquer "Télécharger" sur épisode 1
|
||||||
|
4. Vérifier dans /downloads
|
||||||
|
Expected: Le téléchargement commence
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario: Auto-download**
|
||||||
|
```
|
||||||
|
1. Ajouter un anime avec auto-download activé
|
||||||
|
2. Simuler l'apparition d'un nouvel épisode (via scheduler)
|
||||||
|
3. Vérifier dans les downloads
|
||||||
|
Expected: L'épisode est téléchargé automatiquement
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario: Notification**
|
||||||
|
```
|
||||||
|
1. Un nouvel épisode est détecté
|
||||||
|
2. Une notification apparaît
|
||||||
|
3. Cliquer sur la notification
|
||||||
|
Expected: Redirection vers l'épisode
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères de Succès
|
||||||
|
|
||||||
|
- [ ] La base SQLite est créée et fonctionnelle
|
||||||
|
- [ ] Les animes peuvent être ajoutés/retirés de la watchlist
|
||||||
|
- [ ] Les épisodes peuvent être téléchargés manuellement
|
||||||
|
- [ ] Le scheduler vérifie automatiquement les nouveaux épisodes
|
||||||
|
- [ ] L'auto-téléchargement fonctionne
|
||||||
|
- [ ] Les notifications sont créées et affichées
|
||||||
|
- [ ] L'interface est cohérente avec le reste du site
|
||||||
|
- [ ] L'ancien code est nettoyé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Strategy
|
||||||
|
|
||||||
|
- Wave 1: `feat(watchlist): add SQLite database and models`
|
||||||
|
- Wave 2: `feat(watchlist): add API routes and scheduler`
|
||||||
|
- Wave 3: `feat(watchlist): add frontend UI`
|
||||||
|
- Wave 4: `feat(watchlist): integrate and test`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Le système de download existant (`download_manager`) est réutilisé
|
||||||
|
- Les providers existants (anime-sama, vostfree, etc.) sont réutilisés
|
||||||
|
- Le système de notification est simple (in-app) pour éviter les dépendances supplémentaires
|
||||||
|
- Le scheduler utilise APScheduler déjà présent dans le projet
|
||||||
@@ -1,182 +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
|
|
||||||
pytest --cov=app --cov-report=html
|
|
||||||
|
|
||||||
# Unit only (fast)
|
|
||||||
pytest -m "unit"
|
|
||||||
|
|
||||||
# Exclude slow tests
|
|
||||||
pytest -m "not slow"
|
|
||||||
|
|
||||||
# Verbose with print debugging
|
|
||||||
pytest -v -s
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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`
|
- **Async everywhere**: Always `async/await` for I/O — use `httpx.AsyncClient`, never `requests`
|
||||||
- Use fixtures from `tests/conftest.py`
|
- **Factory pattern**: `get_downloader(url)` routes anime → series → video player → generic
|
||||||
|
- **Router deps**: `Depends(lambda: download_manager)`, `Depends(get_current_user_from_token)`, `Depends(lambda: templates)`
|
||||||
|
- **Dual storage**: Some features use JSON files (legacy) + SQLModel tables (newer)
|
||||||
|
- **Frontend**: No JS build step. HTMX for server interactions, Alpine.js for client state, Plyr.io for video
|
||||||
|
- **Circular import avoidance**: `episode_checker.py` uses lazy init (`set_download_manager()`)
|
||||||
|
|
||||||
```python
|
## 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security
|
- Use sync `requests` — always `httpx.AsyncClient`
|
||||||
- Never hardcode secrets - use environment variables
|
- Return `None` for missing URLs from downloaders — raise an exception
|
||||||
- Validate all inputs (URLs, filenames)
|
- Skip `sanitize_filename()` on extracted filenames — path traversal risk
|
||||||
- Use HMAC for webhook verification when configured
|
- Forget `await self.close()` in downloaders — resource leak
|
||||||
- Limit CORS origins - never use `*` in production
|
- 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)
|
||||||
|
|
||||||
## Architecture Patterns
|
## TEST CONVENTIONS
|
||||||
|
|
||||||
**Three-Tier Downloader:**
|
- `tests/` directory with `conftest.py` for shared fixtures
|
||||||
1. `app/downloaders/anime_sites/` - Anime catalogs
|
- Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.network`, `@pytest.mark.slow`
|
||||||
2. `app/downloaders/series_sites/` - TV series catalogs
|
- `asyncio_mode = auto` — async test functions run without explicit marker
|
||||||
3. `app/downloaders/video_players/` - File hosting
|
- Test naming: `test_<verb>_<noun>` in `Test*` classes
|
||||||
|
- 300s timeout configured in pytest.ini; `testpaths = tests`
|
||||||
|
- Legacy test files at project root: `test_watchlist_simple.py`, `test_watchlist_e2e.py`
|
||||||
|
|
||||||
Each has base class and factory. When adding providers:
|
## ADDING NEW PROVIDERS
|
||||||
1. Inherit from appropriate base class
|
|
||||||
2. Implement required methods
|
|
||||||
3. Register in factory
|
|
||||||
4. Add to providers config in `app/providers.py`
|
|
||||||
|
|
||||||
**URL Convention**: Pipe-separated format preserves metadata:
|
**Video player**: Create in `app/downloaders/video_players/`, inherit `BaseVideoPlayer`,
|
||||||
```
|
implement `can_handle()` + `get_download_link(url, target_filename=None)`, register in
|
||||||
video_url|anime_page_url|episode_title
|
`__init__.py`, add to `FILE_HOSTS` in `providers.py`.
|
||||||
```
|
|
||||||
|
|
||||||
## Key Files
|
**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`.
|
||||||
|
|
||||||
| File | Purpose |
|
## NOTES
|
||||||
|------|---------|
|
|
||||||
| `main.py` | FastAPI app, endpoints |
|
|
||||||
| `app/config.py` | Pydantic Settings |
|
|
||||||
| `app/download_manager.py` | Download queue |
|
|
||||||
| `app/utils.py` | sanitize_filename |
|
|
||||||
| `app/auth.py` | JWT auth |
|
|
||||||
| `app/models/__init__.py` | Pydantic models |
|
|
||||||
|
|
||||||
## Configuration
|
- Python 3.11+, CI tests on 3.11 and 3.12
|
||||||
|
- No `pyproject.toml` — uses `requirements.txt` with exact version pinning
|
||||||
- Use `.env` from `.env.example`
|
- `GEMINI.md` and `CLAUDE.md` exist with tool-specific instructions (merge if conflicting)
|
||||||
- JWT_SECRET_KEY must change in production
|
- French-language project (animes, séries, VOSTFR) but all code and comments in English
|
||||||
|
- ~20 empty `except:` blocks in downloaders/tests — known tech debt
|
||||||
|
- `JWT_SECRET_KEY` min 32 chars, default rejected at startup; generate via `Settings.generate_secret()`
|
||||||
|
- Sub-AGENTS.md files exist in `app/`, `app/routers/`, `app/downloaders/`, `app/models/`,
|
||||||
|
`app/downloaders/anime_sites/`, `app/downloaders/video_players/` for deeper context
|
||||||
|
|||||||
@@ -16,12 +16,17 @@ source venv/bin/activate # On Windows: venv\Scripts\activate
|
|||||||
# Install dependencies
|
# 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*
|
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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:
|
||||||
raise ValueError(f"Username '{username}' already exists")
|
# Check if user already exists
|
||||||
|
statement = select(UserTable).where(UserTable.username == username)
|
||||||
|
if session.exec(statement).first():
|
||||||
|
raise ValueError(f"Username '{username}' already exists")
|
||||||
|
|
||||||
# Truncate password to 72 bytes if necessary (bcrypt limitation)
|
# 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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,26 +205,33 @@ 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:
|
||||||
logger.debug(f"Extracted target filename from pipe: {target_filename}")
|
# Get the last part as episode title
|
||||||
|
potential_title = parts[-1].strip()
|
||||||
|
# Only use it if it looks like a title (not a URL)
|
||||||
|
if potential_title and not potential_title.startswith('http'):
|
||||||
|
target_filename = potential_title
|
||||||
|
logger.debug(f"Extracted target filename from pipe: {target_filename}")
|
||||||
|
|
||||||
download_url, filename = await downloader.get_download_link(task.url, target_filename)
|
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:
|
||||||
shutil.move(download_url, task.file_path)
|
try:
|
||||||
logger.debug(f"Moved file to: {task.file_path}")
|
shutil.move(download_url, 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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -41,15 +41,28 @@ class EpisodeChecker:
|
|||||||
|
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from app.downloaders import get_downloader
|
from app.downloaders import get_downloader
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
# Decode URL if it's encoded (handles double-encoded URLs)
|
||||||
|
anime_url = item.anime_url
|
||||||
|
try:
|
||||||
|
# Try to decode - if already decoded, this will be a no-op
|
||||||
|
decoded_url = unquote(anime_url)
|
||||||
|
# Handle double encoding
|
||||||
|
if '%' in decoded_url:
|
||||||
|
decoded_url = unquote(decoded_url)
|
||||||
|
anime_url = decoded_url
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not decode URL: {e}, using original")
|
||||||
|
|
||||||
# Get the appropriate downloader
|
# Get the appropriate downloader
|
||||||
downloader = get_downloader(item.anime_url)
|
downloader = get_downloader(anime_url)
|
||||||
if not downloader:
|
if not downloader:
|
||||||
logger.error(f"No downloader found for URL: {item.anime_url}")
|
logger.error(f"No downloader found for URL: {anime_url}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Get episodes list
|
# Get episodes list
|
||||||
episodes = await downloader.get_episodes(item.anime_url, item.lang)
|
episodes = await downloader.get_episodes(anime_url, item.lang)
|
||||||
if not episodes:
|
if not episodes:
|
||||||
logger.warning(f"No episodes found for {item.anime_title}")
|
logger.warning(f"No episodes found for {item.anime_title}")
|
||||||
return []
|
return []
|
||||||
@@ -57,7 +70,14 @@ class EpisodeChecker:
|
|||||||
# Filter new episodes
|
# Filter new episodes
|
||||||
new_episodes = []
|
new_episodes = []
|
||||||
for ep in episodes:
|
for ep in episodes:
|
||||||
ep_num = ep.get('episode_number', 0)
|
# Handle both 'episode' (from anime-sama) and 'episode_number' keys
|
||||||
|
ep_num_raw = ep.get('episode_number') or ep.get('episode')
|
||||||
|
# Convert to int (handles string episode numbers like "01", "02")
|
||||||
|
try:
|
||||||
|
ep_num = int(str(ep_num_raw).lstrip('0') or '0')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
ep_num = 0
|
||||||
|
|
||||||
if ep_num > item.last_episode_downloaded:
|
if ep_num > item.last_episode_downloaded:
|
||||||
new_episodes.append(NewEpisodeInfo(
|
new_episodes.append(NewEpisodeInfo(
|
||||||
episode_number=ep_num,
|
episode_number=ep_num,
|
||||||
@@ -113,15 +133,26 @@ class EpisodeChecker:
|
|||||||
try:
|
try:
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from app.downloaders import get_downloader
|
from app.downloaders import get_downloader
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
downloader = get_downloader(item.anime_url)
|
# Decode URL if it's encoded
|
||||||
|
anime_url = item.anime_url
|
||||||
|
try:
|
||||||
|
decoded_url = unquote(anime_url)
|
||||||
|
if '%' in decoded_url:
|
||||||
|
decoded_url = unquote(decoded_url)
|
||||||
|
anime_url = decoded_url
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
downloader = get_downloader(anime_url)
|
||||||
|
|
||||||
# Download each new episode
|
# Download each new episode
|
||||||
for ep_info in episodes:
|
for ep_info in episodes:
|
||||||
try:
|
try:
|
||||||
logger.info(f"Downloading {item.anime_title} Episode {ep_info.episode_number}")
|
logger.info(f"Downloading {item.anime_title} Episode {ep_info.episode_number}")
|
||||||
|
|
||||||
# Get download link
|
# Get download link - episode_url may be pipe-separated with multiple sources
|
||||||
download_link, filename = await downloader.get_download_link(ep_info.episode_url)
|
download_link, filename = await downloader.get_download_link(ep_info.episode_url)
|
||||||
|
|
||||||
# Create download task
|
# Create download task
|
||||||
|
|||||||
@@ -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())
|
if filter_provider:
|
||||||
|
statement = statement.where(FavoriteTable.provider == filter_provider)
|
||||||
|
|
||||||
# Apply filters
|
# SQLite JSON filtering for genres is complex, handle it in Python
|
||||||
if filter_provider:
|
results = session.exec(statement).all()
|
||||||
favorites = [f for f in favorites if f["provider"] == filter_provider]
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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()
|
||||||