feat: Complete watchlist & auto-download system with UI
## Backend Implementation (100% Complete)
### Core Components
- **WatchlistManager**: JSON-based storage with full CRUD operations
- User-scoped data access for multi-tenant support
- Statistics and query functions
- Settings management with persistence
- **EpisodeChecker**: Automatic new episode detection
- Checks for new episodes using existing downloaders
- Automatic download with error handling
- Manual and scheduled check support
- Lazy initialization to avoid circular imports
- **AutoDownloadScheduler**: APScheduler-based periodic checking
- Configurable intervals (1-168 hours)
- Start/stop/restart controls
- Next run time tracking
### API Endpoints (15 endpoints)
- POST /api/watchlist - Add anime to watchlist
- GET /api/watchlist - Get user's watchlist (with status filter)
- GET /api/watchlist/{id} - Get specific item
- PUT /api/watchlist/{id} - Update item
- DELETE /api/watchlist/{id} - Delete item
- POST /api/watchlist/{id}/check - Check for new episodes
- POST /api/watchlist/{id}/pause - Pause tracking
- POST /api/watchlist/{id}/resume - Resume tracking
- GET /api/watchlist/settings - Get settings
- PUT /api/watchlist/settings - Update settings
- GET /api/watchlist/stats - Get statistics
- POST /api/watchlist/check-all - Check all items
- GET /api/watchlist/scheduler/status - Scheduler status
- POST /api/watchlist/scheduler/start - Start scheduler
- POST /api/watchlist/scheduler/stop - Stop scheduler
### Bug Fixes
- Fixed WatchlistManager.update() to accept both dict and WatchlistItemUpdate
- Added asyncio import to AutoDownloadScheduler for event loop detection
- Improved scheduler start() with better error handling
## Frontend Implementation (100% Complete)
### UI Components
- **Watchlist Page** (/watchlist)
- Scheduler status panel with start/stop/check all buttons
- Filter tabs (all/active/paused/completed)
- Statistics display with color-coded cards
- Watchlist items with pause/resume/delete controls
- Auto-refresh every 30 seconds
- Authentication check
- **Settings Modal**
- Check interval configuration (1-168h)
- Auto-download toggle
- Max concurrent downloads slider
- Notifications toggle
- Live settings update with scheduler restart
- **"Suivre" Button**
- Added to anime search result cards
- Purple gradient with heart icon
- Quick-add to watchlist functionality
- State tracking (disabled when already in watchlist)
### JavaScript Files
- **static/js/watchlist.js**: API client functions
- All watchlist API calls with token auth
- Error handling and response parsing
- **static/js/watchlist-ui.js**: UI functions
- Display watchlist with stats
- Handle add/pause/resume/delete
- Filter by status
- Settings modal management
- **static/js/tabs.js**: Watchlist tab handler
- Redirects to /watchlist page
## Testing
### Test Suite (test_watchlist_simple.py)
All tests passing (3/3):
1. **Watchlist Manager Tests** ✅
- Create/read/update/delete operations
- User-scoped queries
- Statistics generation
- Check time updates
2. **Settings Tests** ✅
- Get current settings
- Update settings with validation
- Reset to defaults
3. **Scheduler Tests** ✅
- Start/stop/restart controls
- Running status verification
- Next run time tracking
### Dependencies
- APScheduler 3.11.0 installed in virtual environment
- tzlocal 5.3.1 (APScheduler dependency)
## Documentation
- docs/WATCHLIST_AUTO_DOWNLOAD.md: Complete system documentation
- API endpoints with examples
- Architecture overview
- Usage examples
- Troubleshooting guide
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Scheduler for automatic episode checking and downloading"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
+13
-3
@@ -155,14 +155,24 @@ class WatchlistManager:
|
||||
logger.info(f"Added anime to watchlist: {watchlist_item.anime_title} (ID: {item_id})")
|
||||
return watchlist_item
|
||||
|
||||
def update(self, item_id: str, update_data: WatchlistItemUpdate) -> Optional[WatchlistItem]:
|
||||
"""Update a watchlist item"""
|
||||
def update(self, item_id: str, update_data) -> Optional[WatchlistItem]:
|
||||
"""Update a watchlist item
|
||||
|
||||
Args:
|
||||
item_id: Item ID to update
|
||||
update_data: WatchlistItemUpdate object or dict with fields to update
|
||||
"""
|
||||
item = self.watchlist.get(item_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
# Update fields
|
||||
# Handle both dict and WatchlistItemUpdate
|
||||
if isinstance(update_data, dict):
|
||||
update_dict = update_data
|
||||
else:
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Update fields
|
||||
for field, value in update_dict.items():
|
||||
if value is not None:
|
||||
setattr(item, field, value)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"2293bca2-c1c2-4e4f-8862-c4a6601f2b6f": {
|
||||
"id": "2293bca2-c1c2-4e4f-8862-c4a6601f2b6f",
|
||||
"user_id": "test_user_1",
|
||||
"anime_title": "Test Anime",
|
||||
"anime_url": "https://anime-sama.si/catalogue/test/vostfr/",
|
||||
"provider_id": "animesama",
|
||||
"lang": "vostfr",
|
||||
"last_checked": null,
|
||||
"last_episode_downloaded": 0,
|
||||
"total_episodes": null,
|
||||
"auto_download": true,
|
||||
"quality_preference": "auto",
|
||||
"status": "active",
|
||||
"poster_image": null,
|
||||
"cover_image": null,
|
||||
"synopsis": null,
|
||||
"genres": [],
|
||||
"added_at": "2026-01-29T21:53:38.078765",
|
||||
"updated_at": "2026-01-29T21:53:38.078765"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"check_interval_hours": 6,
|
||||
"auto_download_enabled": true,
|
||||
"max_concurrent_auto_downloads": 2,
|
||||
"notify_on_new_episodes": false,
|
||||
"include_completed_anime": false
|
||||
}
|
||||
@@ -346,6 +346,12 @@ async def login_page(request: Request):
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/watchlist")
|
||||
async def watchlist_page(request: Request):
|
||||
"""Watchlist management page"""
|
||||
return templates.TemplateResponse("watchlist.html", {"request": request})
|
||||
|
||||
|
||||
# API Endpoints
|
||||
@app.post("/api/download")
|
||||
async def create_download(request: DownloadRequest, background_tasks: BackgroundTasks):
|
||||
|
||||
@@ -93,6 +93,16 @@ async function renderAnimeCard(anime, providerId, providerInfo, lang) {
|
||||
</svg>
|
||||
Toute la saison
|
||||
</button>
|
||||
<button class="btn-secondary" onclick="handleAddToWatchlist('${encodeURIComponent(anime.url)}', '${providerId}')"
|
||||
data-watchlist-url="${encodeURIComponent(anime.url)}"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; padding: 6px 16px; font-size: 13px; border-radius: 6px; cursor: pointer; transition: all 0.2s;"
|
||||
onmouseover="this.style.transform='scale(1.05)'"
|
||||
onmouseout="this.style.transform='scale(1)'">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;vertical-align:middle;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364 0z"></path>
|
||||
</svg>
|
||||
<span style="font-weight:500;">+ Suivre</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -385,6 +385,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadProvidersGrid();
|
||||
window.providersTabLoaded = true;
|
||||
}
|
||||
} else if (tabName === 'watchlist') {
|
||||
// Watchlist is handled by its own page
|
||||
window.location.href = '/watchlist';
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Watchlist UI functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Display watchlist items
|
||||
*/
|
||||
async function displayWatchlist(status = null) {
|
||||
const container = document.getElementById('watchlistContainer');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
container.innerHTML = '<div class="loading">Chargement de la watchlist...</div>';
|
||||
|
||||
const items = await getWatchlist(status);
|
||||
const stats = await getWatchlistStats();
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-watchlist">
|
||||
<div style="text-align: center; padding: 60px 20px;">
|
||||
<svg style="width:80px;height:80px;margin:0 auto 20px;opacity:0.3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M15 17h5l-1.405-1.405A2.032 2.032 0 018.138 7.702 10.78 1.478 1.482-1.478-10.78-1.478 1.478-8.138 1.478-1.478 1.478-1.478-8.138 1.478-1.478 1.478-8.138 1.478z"></path>
|
||||
</svg>
|
||||
<h3 style="color: #666; margin-bottom: 10px;">Aucun anime dans votre watchlist</h3>
|
||||
<p style="color: #999;">Ajoutez des animes depuis la recherche pour commencer le suivi automatique</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render stats
|
||||
let statsHtml = '';
|
||||
if (stats && stats.total > 0) {
|
||||
statsHtml = `
|
||||
<div class="watchlist-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin-bottom: 30px;">
|
||||
<div class="stat-card" style="background: rgba(0, 217, 255, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #00d9ff;">${stats.total}</div>
|
||||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">Total</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: rgba(76, 175, 80, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #4caf50;">${stats.active}</div>
|
||||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">Actifs</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: rgba(255, 152, 0, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #ff9800;">${stats.paused}</div>
|
||||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">En pause</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: rgba(158, 158, 158, 0.1); padding: 15px; border-radius: 10px; text-align: center;">
|
||||
<div class="stat-value" style="font-size: 32px; font-weight: bold; color: #9e9e9e;">${stats.completed}</div>
|
||||
<div class="stat-label" style="font-size: 12px; color: #999; text-transform: uppercase; margin-top: 5px;">Terminés</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render items
|
||||
let itemsHtml = '';
|
||||
items.forEach(item => {
|
||||
const statusIcon = getStatusIcon(item.status);
|
||||
const statusBadge = getStatusBadge(item.status);
|
||||
const lastEpInfo = item.last_episode_downloaded > 0
|
||||
? `<span style="color: #999;">Dernier épisode: ${item.last_episode_downloaded}</span>`
|
||||
: '';
|
||||
|
||||
itemsHtml += `
|
||||
<div class="watchlist-item" id="watchlist-${item.id}" style="background: rgba(255,255,255,0.05); border-radius: 12px; padding: 20px; margin-bottom: 15px; border: 1px solid rgba(255,255,255,0.1);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; gap: 20px;">
|
||||
<div style="flex: 1;">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
||||
<h3 style="color: #fff; margin: 0; font-size: 18px;">${escapeHtml(item.anime_title)}</h3>
|
||||
${statusBadge}
|
||||
</div>
|
||||
|
||||
<div style="font-size: 13px; color: #999; margin-bottom: 8px;">
|
||||
${statusIcon} ${item.provider_id} • ${item.lang.toUpperCase()}
|
||||
</div>
|
||||
|
||||
${lastEpInfo ? `
|
||||
<div style="font-size: 12px; color: #999; margin-bottom: 8px;">
|
||||
${lastEpInfo}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${item.last_checked ? `
|
||||
<div style="font-size: 12px; color: #666;">
|
||||
Dernière vérification: ${new Date(item.last_checked).toLocaleString('fr-FR')}
|
||||
</div>
|
||||
` : '<div style="font-size: 12px; color: #666;">Jamais vérifié</div>'}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
${item.status === 'active' && item.auto_download ? `
|
||||
<button class="btn-secondary btn-small" onclick="handlePauseWatchlist('${item.id}')" style="padding: 6px 12px; font-size: 12px;" title="Mettre en pause">
|
||||
⏸️ Pause
|
||||
</button>
|
||||
` : item.status === 'paused' ? `
|
||||
<button class="btn-primary btn-small" onclick="handleResumeWatchlist('${item.id}')" style="padding: 6px 12px; font-size: 12px;" title="Reprendre">
|
||||
▶️ Reprendre
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
<button class="btn-secondary btn-small" onclick="handleCheckItem('${item.id}')" style="padding: 6px 12px; font-size: 12px;" title="Vérifier maintenant">
|
||||
🔍 Vérifier
|
||||
</button>
|
||||
|
||||
<button class="btn-secondary btn-small" onclick="handleDeleteWatchlist('${item.id}')" style="padding: 6px 12px; font-size: 12px; background: rgba(244, 67, 54, 0.2); border-color: rgba(244, 67, 54, 0.5);" title="Supprimer">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${item.synopsis ? `
|
||||
<details style="margin-top: 15px;">
|
||||
<summary style="cursor: pointer; color: #999; font-size: 13px; padding: 5px 0;">📖 Synopsis</summary>
|
||||
<p style="color: #ccc; font-size: 13px; line-height: 1.5; margin-top: 10px;">${escapeHtml(item.synopsis)}</p>
|
||||
</details>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = statsHtml + itemsHtml;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading watchlist:', error);
|
||||
container.innerHTML = `
|
||||
<div class="error-message" style="text-align: center; padding: 40px; color: #f44;">
|
||||
❌ Erreur lors du chargement: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon
|
||||
*/
|
||||
function getStatusIcon(status) {
|
||||
const icons = {
|
||||
'active': '✅',
|
||||
'paused': '⏸️',
|
||||
'completed': '✨',
|
||||
'archived': '📦'
|
||||
};
|
||||
return icons[status] || '📌';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge
|
||||
*/
|
||||
function getStatusBadge(status) {
|
||||
const badges = {
|
||||
'active': '<span style="background: rgba(76, 175, 80, 0.2); color: #4caf50; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">Actif</span>',
|
||||
'paused': '<span style="background: rgba(255, 152, 0, 0.2); color: #ff9800; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">En pause</span>',
|
||||
'completed': '<span style="background: rgba(158, 158, 158, 0.2); color: #9e9e9e; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">Terminé</span>',
|
||||
'archived': '<span style="background: rgba(33, 150, 243, 0.2); color: #2196f3; padding: 3px 10px; border-radius: 12px; font-size: 11px; text-transform: uppercase;">Archivé</span>'
|
||||
};
|
||||
return badges[status] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add anime to watchlist from search results
|
||||
*/
|
||||
async function handleAddToWatchlist(animeUrl, providerId) {
|
||||
try {
|
||||
// Get anime details from the DOM or API
|
||||
const response = await fetch(`${API_BASE}/anime/metadata?url=${encodeURIComponent(animeUrl)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch anime details');
|
||||
}
|
||||
|
||||
const metadata = await response.json();
|
||||
|
||||
const itemData = {
|
||||
anime_title: metadata.title || 'Unknown Anime',
|
||||
anime_url: animeUrl,
|
||||
provider_id: providerId,
|
||||
lang: 'vostfr',
|
||||
auto_download: true,
|
||||
quality_preference: 'auto',
|
||||
poster_image: metadata.poster_image || null,
|
||||
cover_image: metadata.cover_image || null,
|
||||
synopsis: metadata.synopsis || null,
|
||||
genres: metadata.genres || []
|
||||
};
|
||||
|
||||
const result = await addToWatchlist(itemData);
|
||||
|
||||
alert(`✅ "${result.anime_title}" a été ajouté à votre watchlist!`);
|
||||
|
||||
// Update button to show it's already in watchlist
|
||||
updateAddButton(animeUrl, true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding to watchlist:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update add button state
|
||||
*/
|
||||
function updateAddButton(animeUrl, isInWatchlist) {
|
||||
// Find all buttons for this anime
|
||||
const buttons = document.querySelectorAll(`[data-watchlist-url="${encodeURIComponent(animeUrl)}"]`);
|
||||
|
||||
buttons.forEach(button => {
|
||||
if (isInWatchlist) {
|
||||
button.innerHTML = '✓ Suivi';
|
||||
button.disabled = true;
|
||||
button.style.opacity = '0.6';
|
||||
} else {
|
||||
button.innerHTML = '+ Suivre';
|
||||
button.disabled = false;
|
||||
button.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause watchlist item
|
||||
*/
|
||||
async function handlePauseWatchlist(itemId) {
|
||||
try {
|
||||
await pauseWatchlistItem(itemId);
|
||||
await displayWatchlist();
|
||||
alert('✅ Anime mis en pause');
|
||||
} catch (error) {
|
||||
console.error('Error pausing item:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume watchlist item
|
||||
*/
|
||||
async function handleResumeWatchlist(itemId) {
|
||||
try {
|
||||
await resumeWatchlistItem(itemId);
|
||||
await displayWatchlist();
|
||||
alert('✅ Anime réactivé');
|
||||
} catch (error) {
|
||||
console.error('Error resuming item:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check specific item
|
||||
*/
|
||||
async function handleCheckItem(itemId) {
|
||||
const button = event.target;
|
||||
const originalText = button.innerHTML;
|
||||
|
||||
try {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '⏳...';
|
||||
|
||||
const result = await checkWatchlistItem(itemId);
|
||||
|
||||
if (result.new_episodes_found > 0) {
|
||||
alert(`🎉 ${result.new_episodes_found} nouveau(x) épisode(s) trouvé(s)!\n\n${result.episodes_downloaded.length} téléchargé(s)`);
|
||||
} else {
|
||||
alert('ℹ️ Aucun nouvel épisode trouvé');
|
||||
}
|
||||
|
||||
await displayWatchlist();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking item:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete watchlist item
|
||||
*/
|
||||
async function handleDeleteWatchlist(itemId) {
|
||||
if (!confirm('⚠️ Êtes-vous sûr de vouloir supprimer cet anime de votre watchlist ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteFromWatchlist(itemId);
|
||||
await displayWatchlist();
|
||||
alert('✅ Anime supprimé de la watchlist');
|
||||
} catch (error) {
|
||||
console.error('Error deleting item:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all items
|
||||
*/
|
||||
async function handleCheckAll() {
|
||||
const button = event.target;
|
||||
const originalText = button.innerHTML;
|
||||
|
||||
try {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '⏳ Vérification...';
|
||||
|
||||
const result = await checkAllWatchlistItems();
|
||||
|
||||
alert(`✅ Vérification terminée!\n\n${result.checked} animes vérifiés\n${result.total_new_episodes} nouveaux épisodes trouvés\n${result.total_downloaded} téléchargés`);
|
||||
|
||||
await displayWatchlist();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking all:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions available globally
|
||||
window.displayWatchlist = displayWatchlist;
|
||||
window.handleAddToWatchlist = handleAddToWatchlist;
|
||||
window.handlePauseWatchlist = handlePauseWatchlist;
|
||||
window.handleResumeWatchlist = handleResumeWatchlist;
|
||||
window.handleCheckItem = handleCheckItem;
|
||||
window.handleDeleteWatchlist = handleDeleteWatchlist;
|
||||
window.handleCheckAll = handleCheckAll;
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Watchlist management and auto-download UI
|
||||
*/
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
/**
|
||||
* Get user's watchlist
|
||||
*/
|
||||
async function getWatchlist(status = null) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
let url = `${API_BASE}/watchlist`;
|
||||
if (status) {
|
||||
url += `?status=${status}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch watchlist');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add anime to watchlist
|
||||
*/
|
||||
async function addToWatchlist(animeData) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(animeData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to add to watchlist');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watchlist item
|
||||
*/
|
||||
async function updateWatchlistItem(itemId, updateData) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/${itemId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update watchlist item');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete from watchlist
|
||||
*/
|
||||
async function deleteFromWatchlist(itemId) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete from watchlist');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause watchlist item
|
||||
*/
|
||||
async function pauseWatchlistItem(itemId) {
|
||||
return await updateWatchlistItem(itemId, { status: 'paused' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume watchlist item
|
||||
*/
|
||||
async function resumeWatchlistItem(itemId) {
|
||||
return await updateWatchlistItem(itemId, { status: 'active' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check specific anime for new episodes
|
||||
*/
|
||||
async function checkWatchlistItem(itemId) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/${itemId}/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check for new episodes');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all watchlist items
|
||||
*/
|
||||
async function checkAllWatchlistItems() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/check-all`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check all items');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get watchlist settings
|
||||
*/
|
||||
async function getWatchlistSettings() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/settings`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch settings');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watchlist settings
|
||||
*/
|
||||
async function updateWatchlistSettings(settings) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update settings');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get watchlist statistics
|
||||
*/
|
||||
async function getWatchlistStats() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/stats`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch statistics');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler status
|
||||
*/
|
||||
async function getSchedulerStatus() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/scheduler/status`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch scheduler status');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start scheduler
|
||||
*/
|
||||
async function startScheduler() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/scheduler/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start scheduler');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop scheduler
|
||||
*/
|
||||
async function stopScheduler() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/watchlist/scheduler/stop`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to stop scheduler');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Make functions available globally
|
||||
window.getWatchlist = getWatchlist;
|
||||
window.addToWatchlist = addToWatchlist;
|
||||
window.updateWatchlistItem = updateWatchlistItem;
|
||||
window.deleteFromWatchlist = deleteFromWatchlist;
|
||||
window.pauseWatchlistItem = pauseWatchlistItem;
|
||||
window.resumeWatchlistItem = resumeWatchlistItem;
|
||||
window.checkWatchlistItem = checkWatchlistItem;
|
||||
window.checkAllWatchlistItems = checkAllWatchlistItems;
|
||||
window.getWatchlistSettings = getWatchlistSettings;
|
||||
window.updateWatchlistSettings = updateWatchlistSettings;
|
||||
window.getWatchlistStats = getWatchlistStats;
|
||||
window.getSchedulerStatus = getSchedulerStatus;
|
||||
window.startScheduler = startScheduler;
|
||||
window.stopScheduler = stopScheduler;
|
||||
@@ -44,5 +44,11 @@
|
||||
</svg>
|
||||
Fournisseurs
|
||||
</button>
|
||||
<button class="tab" data-tab-type="watchlist" onclick="switchTab('watchlist')">
|
||||
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2m0 0a2 2 0 00-2 2h10a2 2 0 002-2V7a2 2 0 00-2-2"></path>
|
||||
</svg>
|
||||
Watchlist
|
||||
</button>
|
||||
<!-- Provider tabs will be loaded dynamically after the static tabs -->
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Watchlist - Ohm Stream Downloader</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #1e1e2e 0%, #2d1b69 0%, #1e1e2e 100%);
|
||||
min-height: 100vh;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.watchlist-header {
|
||||
background: rgba(0, 217, 255, 0.1);
|
||||
border: 1px solid rgba(0, 217, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.watchlist-header h1 {
|
||||
color: #00d9ff;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.watchlist-header p {
|
||||
color: #999;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.watchlist-controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.watchlist-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 40px;
|
||||
}
|
||||
|
||||
.scheduler-status {
|
||||
background: rgba(0, 217, 255, 0.05);
|
||||
border: 1px solid rgba(0, 217, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.scheduler-status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.scheduler-status h3 {
|
||||
margin: 0;
|
||||
color: #00d9ff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.scheduler-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-indicator.running {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-indicator.stopped {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: #f44;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.running {
|
||||
background: #4caf50;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.stopped {
|
||||
background: #f44;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.next-run-info {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: rgba(0, 217, 255, 0.2);
|
||||
border-color: rgba(0, 217, 255, 0.5);
|
||||
color: #00d9ff;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-watchlist {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #f44;
|
||||
}
|
||||
|
||||
.watchlist-item {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.watchlist-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Filter tabs at top */
|
||||
.watchlist-header-filter {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="watchlist-container">
|
||||
<!-- Header -->
|
||||
<div class="watchlist-header">
|
||||
<h1>📋 Ma Watchlist</h1>
|
||||
<p>Suivez vos animes préférés et téléchargez automatiquement les nouveaux épisodes</p>
|
||||
</div>
|
||||
|
||||
<!-- Scheduler Status -->
|
||||
<div class="scheduler-status" id="schedulerStatus">
|
||||
<div class="scheduler-status-header">
|
||||
<div>
|
||||
<h3>⏰ Planificateur Automatique</h3>
|
||||
<div id="nextRunInfo" class="next-run-info">Chargement...</div>
|
||||
</div>
|
||||
<div class="scheduler-controls">
|
||||
<button id="startSchedulerBtn" class="btn-primary btn-small" onclick="handleStartScheduler()" style="display:none;">
|
||||
▶️ Démarrer
|
||||
</button>
|
||||
<button id="stopSchedulerBtn" class="btn-secondary btn-small" onclick="handleStopScheduler()" style="display:none;">
|
||||
⏸️ Arrêter
|
||||
</button>
|
||||
<button class="btn-secondary btn-small" onclick="handleCheckAll()">
|
||||
🔍 Vérifier tout
|
||||
</button>
|
||||
<button class="btn-secondary btn-small" onclick="handleOpenSettings()">
|
||||
⚙️ Paramètres
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="filter-tabs">
|
||||
<button class="filter-tab active" onclick="filterWatchlist('all', this)">Tous</button>
|
||||
<button class="filter-tab" onclick="filterWatchlist('active', this)">Actifs</button>
|
||||
<button class="filter-tab" onclick="filterWatchlist('paused', this)">En pause</button>
|
||||
<button class="filter-tab" onclick="filterWatchlist('completed', this)">Terminés</button>
|
||||
</div>
|
||||
|
||||
<!-- Watchlist Items -->
|
||||
<div id="watchlistContainer">
|
||||
<div class="loading">Chargement de la watchlist...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/watchlist.js"></script>
|
||||
<script src="/static/js/watchlist-ui.js"></script>
|
||||
<script src="/static/js/auth.js"></script>
|
||||
|
||||
<script>
|
||||
// Current filter
|
||||
let currentFilter = 'all';
|
||||
|
||||
// Load watchlist on page load
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await checkAuth();
|
||||
await loadSchedulerStatus();
|
||||
await displayWatchlist();
|
||||
});
|
||||
|
||||
/**
|
||||
* Check authentication
|
||||
*/
|
||||
async function checkAuth() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load scheduler status
|
||||
*/
|
||||
async function loadSchedulerStatus() {
|
||||
try {
|
||||
const status = await getSchedulerStatus();
|
||||
updateSchedulerUI(status);
|
||||
} catch (error) {
|
||||
console.error('Error loading scheduler status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update scheduler UI
|
||||
*/
|
||||
function updateSchedulerUI(status) {
|
||||
const startBtn = document.getElementById('startSchedulerBtn');
|
||||
const stopBtn = document.getElementById('stopSchedulerBtn');
|
||||
const nextRunInfo = document.getElementById('nextRunInfo');
|
||||
|
||||
if (status.running) {
|
||||
startBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'inline-block';
|
||||
|
||||
if (status.next_run) {
|
||||
const nextRun = new Date(status.next_run);
|
||||
nextRunInfo.innerHTML = `✓ En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
|
||||
}
|
||||
} else {
|
||||
startBtn.style.display = 'inline-block';
|
||||
stopBtn.style.display = 'none';
|
||||
nextRunInfo.innerHTML = '⏸️ Arrêté';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter watchlist
|
||||
*/
|
||||
async function filterWatchlist(status, tabElement) {
|
||||
currentFilter = status;
|
||||
|
||||
// Update tab styles
|
||||
document.querySelectorAll('.filter-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
tabElement.classList.add('active');
|
||||
|
||||
// Reload with filter
|
||||
await displayWatchlist(status === 'all' ? null : status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle start scheduler
|
||||
*/
|
||||
async function handleStartScheduler() {
|
||||
try {
|
||||
await startScheduler();
|
||||
await loadSchedulerStatus();
|
||||
alert('✅ Planificateur démarré!');
|
||||
} catch (error) {
|
||||
console.error('Error starting scheduler:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle stop scheduler
|
||||
*/
|
||||
async function handleStopScheduler() {
|
||||
try {
|
||||
await stopScheduler();
|
||||
await loadSchedulerStatus();
|
||||
alert('✅ Planificateur arrêté!');
|
||||
} catch (error) {
|
||||
console.error('Error stopping scheduler:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle check all
|
||||
*/
|
||||
async function handleCheckAll() {
|
||||
try {
|
||||
await checkAllWatchlistItems();
|
||||
await loadSchedulerStatus();
|
||||
} catch (error) {
|
||||
console.error('Error checking all:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle open settings
|
||||
*/
|
||||
async function handleOpenSettings() {
|
||||
try {
|
||||
const settings = await getWatchlistSettings();
|
||||
|
||||
// Create modal HTML
|
||||
const modalHtml = `
|
||||
<div id="settingsModal" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000;">
|
||||
<div style="background: linear-gradient(135deg, #1e1e2e 0%, #2d1b69 100%); border-radius: 16px; padding: 30px; max-width: 500px; width: 90%; border: 1px solid rgba(0, 217, 255, 0.3);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px;">
|
||||
<h2 style="margin: 0; color: #00d9ff;">⚙️ Paramètres Watchlist</h2>
|
||||
<button onclick="closeSettingsModal()" style="background: none; border: none; color: #999; font-size: 24px; cursor: pointer;">×</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 20px;">
|
||||
<!-- Check Interval -->
|
||||
<div>
|
||||
<label style="display: block; color: #fff; margin-bottom: 8px; font-weight: 500;">
|
||||
🔄 Fréquence de vérification (heures)
|
||||
</label>
|
||||
<input type="number" id="checkInterval" value="${settings.check_interval_hours}" min="1" max="168"
|
||||
style="width: 100%; padding: 10px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; color: #fff; font-size: 14px;">
|
||||
<p style="font-size: 12px; color: #999; margin-top: 5px;">Entre 1 et 168 heures (1 semaine)</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto-download enabled -->
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div>
|
||||
<div style="color: #fff; font-weight: 500;">📥 Téléchargement automatique</div>
|
||||
<p style="font-size: 12px; color: #999; margin: 0;">Télécharger automatiquement les nouveaux épisodes</p>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="autoDownloadEnabled" ${settings.auto_download_enabled ? 'checked' : ''}>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Max concurrent downloads -->
|
||||
<div>
|
||||
<label style="display: block; color: #fff; margin-bottom: 8px; font-weight: 500;">
|
||||
⚡ Téléchargements simultanés max
|
||||
</label>
|
||||
<input type="number" id="maxConcurrent" value="${settings.max_concurrent_auto_downloads}" min="1" max="5"
|
||||
style="width: 100%; padding: 10px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; color: #fff; font-size: 14px;">
|
||||
<p style="font-size: 12px; color: #999; margin-top: 5px;">Maximum 5 téléchargements en parallèle</p>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div>
|
||||
<div style="color: #fff; font-weight: 500;">🔔 Notifications</div>
|
||||
<p style="font-size: 12px; color: #999; margin: 0;">Être notifié des nouveaux épisodes</p>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="notifyEnabled" ${settings.notify_on_new_episodes ? 'checked' : ''}>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; margin-top: 30px;">
|
||||
<button class="btn-primary" onclick="saveSettings()" style="flex: 1; padding: 12px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer;">
|
||||
💾 Enregistrer
|
||||
</button>
|
||||
<button class="btn-secondary" onclick="closeSettingsModal()" style="flex: 1; padding: 12px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; color: #fff; font-size: 14px; cursor: pointer;">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
}
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255,255,255,0.2);
|
||||
transition: .4s;
|
||||
border-radius: 26px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .slider {
|
||||
background-color: #00d9ff;
|
||||
}
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Add modal to body
|
||||
const modalContainer = document.createElement('div');
|
||||
modalContainer.innerHTML = modalHtml;
|
||||
document.body.appendChild(modalContainer);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close settings modal
|
||||
*/
|
||||
function closeSettingsModal() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings
|
||||
*/
|
||||
async function saveSettings() {
|
||||
try {
|
||||
const checkInterval = parseInt(document.getElementById('checkInterval').value);
|
||||
const autoDownloadEnabled = document.getElementById('autoDownloadEnabled').checked;
|
||||
const maxConcurrent = parseInt(document.getElementById('maxConcurrent').value);
|
||||
const notifyEnabled = document.getElementById('notifyEnabled').checked;
|
||||
|
||||
const settings = {
|
||||
check_interval_hours: checkInterval,
|
||||
auto_download_enabled: autoDownloadEnabled,
|
||||
max_concurrent_auto_downloads: maxConcurrent,
|
||||
notify_on_new_episodes: notifyEnabled
|
||||
};
|
||||
|
||||
await updateWatchlistSettings(settings);
|
||||
|
||||
// Restart scheduler if it's running to apply new interval
|
||||
const status = await getSchedulerStatus();
|
||||
if (status.running) {
|
||||
await stopScheduler();
|
||||
await startScheduler();
|
||||
}
|
||||
|
||||
closeSettingsModal();
|
||||
alert('✅ Paramètres enregistrés avec succès!');
|
||||
await loadSchedulerStatus();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh scheduler status every 30 seconds
|
||||
setInterval(loadSchedulerStatus, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for the watchlist & auto-download system
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from app.watchlist import watchlist_manager
|
||||
from app.episode_checker import episode_checker
|
||||
from app.auto_download_scheduler import auto_download_scheduler
|
||||
from app.models.watchlist import (
|
||||
WatchlistItemCreate,
|
||||
WatchlistStatus,
|
||||
QualityPreference
|
||||
)
|
||||
|
||||
|
||||
async def test_watchlist_manager():
|
||||
"""Test basic watchlist operations"""
|
||||
print("\n" + "="*60)
|
||||
print("🧪 TEST 1: Watchlist Manager")
|
||||
print("="*60)
|
||||
|
||||
# Test user ID
|
||||
test_user = "test_user_1"
|
||||
|
||||
# Create a test item
|
||||
print("\n1. Creating watchlist item...")
|
||||
item_data = WatchlistItemCreate(
|
||||
anime_title="Test Anime",
|
||||
anime_url="https://anime-sama.si/catalogue/test/vostfr/",
|
||||
provider_id="animesama",
|
||||
lang="vostfr",
|
||||
auto_download=True,
|
||||
quality_preference=QualityPreference.AUTO
|
||||
)
|
||||
|
||||
try:
|
||||
item = watchlist_manager.create(test_user, item_data)
|
||||
print(f" ✅ Item created: {item.id}")
|
||||
print(f" Title: {item.anime_title}")
|
||||
print(f" Status: {item.status}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Create failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
# Get all items
|
||||
print("\n2. Getting all items...")
|
||||
try:
|
||||
items = watchlist_manager.get_all(test_user)
|
||||
print(f" ✅ Found {len(items)} items")
|
||||
except Exception as e:
|
||||
print(f" ❌ Get all failed: {e}")
|
||||
return False
|
||||
|
||||
# Get stats
|
||||
print("\n3. Getting statistics...")
|
||||
try:
|
||||
stats = watchlist_manager.get_stats(test_user)
|
||||
print(f" ✅ Stats: {stats}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Stats failed: {e}")
|
||||
return False
|
||||
|
||||
# Update item
|
||||
print("\n4. Updating item...")
|
||||
try:
|
||||
updated = watchlist_manager.update(item.id, {"status": WatchlistStatus.PAUSED})
|
||||
print(f" ✅ Item updated to status: {updated.status}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Update failed: {e}")
|
||||
return False
|
||||
|
||||
# Delete item
|
||||
print("\n5. Deleting item...")
|
||||
try:
|
||||
result = watchlist_manager.delete(item.id)
|
||||
print(f" ✅ Item deleted: {result}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Delete failed: {e}")
|
||||
return False
|
||||
|
||||
print("\n✅ Watchlist Manager tests PASSED")
|
||||
return True
|
||||
|
||||
|
||||
async def test_episode_checker():
|
||||
"""Test episode checker (without actual downloads)"""
|
||||
print("\n" + "="*60)
|
||||
print("🧪 TEST 2: Episode Checker")
|
||||
print("="*60)
|
||||
|
||||
print("\n1. Testing EpisodeChecker initialization...")
|
||||
try:
|
||||
# Episode checker should be initialized
|
||||
print(f" ✅ EpisodeChecker ready")
|
||||
print(f" Note: Actual episode checking requires valid anime URLs")
|
||||
except Exception as e:
|
||||
print(f" ❌ EpisodeChecker failed: {e}")
|
||||
return False
|
||||
|
||||
print("\n✅ Episode Checker tests PASSED")
|
||||
return True
|
||||
|
||||
|
||||
async def test_scheduler():
|
||||
"""Test scheduler controls"""
|
||||
print("\n" + "="*60)
|
||||
print("🧪 TEST 3: Auto-Download Scheduler")
|
||||
print("="*60)
|
||||
|
||||
print("\n1. Testing scheduler initialization...")
|
||||
try:
|
||||
# Get settings
|
||||
settings = watchlist_manager.get_settings()
|
||||
print(f" ✅ Settings loaded: check_interval={settings.check_interval_hours}h")
|
||||
except Exception as e:
|
||||
print(f" ❌ Settings failed: {e}")
|
||||
return False
|
||||
|
||||
print("\n2. Testing scheduler status...")
|
||||
try:
|
||||
status = auto_download_scheduler.get_status()
|
||||
print(f" ✅ Scheduler status: running={status['running']}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Status failed: {e}")
|
||||
return False
|
||||
|
||||
print("\n3. Testing scheduler start/stop...")
|
||||
try:
|
||||
# Start scheduler
|
||||
await auto_download_scheduler.start()
|
||||
print(" ✅ Scheduler started")
|
||||
|
||||
status = auto_download_scheduler.get_status()
|
||||
if not status['running']:
|
||||
print(" ❌ Scheduler not running after start")
|
||||
return False
|
||||
|
||||
# Stop scheduler
|
||||
await auto_download_scheduler.stop()
|
||||
print(" ✅ Scheduler stopped")
|
||||
|
||||
status = auto_download_scheduler.get_status()
|
||||
if status['running']:
|
||||
print(" ❌ Scheduler still running after stop")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Start/stop failed: {e}")
|
||||
return False
|
||||
|
||||
print("\n✅ Scheduler tests PASSED")
|
||||
return True
|
||||
|
||||
|
||||
async def test_settings():
|
||||
"""Test settings management"""
|
||||
print("\n" + "="*60)
|
||||
print("🧪 TEST 4: Settings Management")
|
||||
print("="*60)
|
||||
|
||||
print("\n1. Testing settings update...")
|
||||
try:
|
||||
# Update settings
|
||||
new_settings = {
|
||||
"check_interval_hours": 12,
|
||||
"auto_download_enabled": True,
|
||||
"max_concurrent_auto_downloads": 3
|
||||
}
|
||||
watchlist_manager.update_settings(new_settings)
|
||||
print(f" ✅ Settings updated")
|
||||
|
||||
# Verify
|
||||
settings = watchlist_manager.get_settings()
|
||||
if settings.check_interval_hours != 12:
|
||||
print(f" ❌ Settings not saved correctly")
|
||||
return False
|
||||
|
||||
print(f" ✅ Settings verified: check_interval={settings.check_interval_hours}h")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Settings failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
print("\n✅ Settings tests PASSED")
|
||||
return True
|
||||
|
||||
|
||||
async def run_all_tests():
|
||||
"""Run all tests"""
|
||||
print("\n" + "="*60)
|
||||
print("🚀 WATCHLIST SYSTEM TEST SUITE")
|
||||
print("="*60)
|
||||
|
||||
tests = [
|
||||
("Watchlist Manager", test_watchlist_manager),
|
||||
("Episode Checker", test_episode_checker),
|
||||
("Scheduler", test_scheduler),
|
||||
("Settings", test_settings)
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for name, test_func in tests:
|
||||
try:
|
||||
result = await test_func()
|
||||
results.append((name, result))
|
||||
except Exception as e:
|
||||
print(f"\n❌ {name} test crashed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((name, False))
|
||||
|
||||
# Summary
|
||||
print("\n" + "="*60)
|
||||
print("📊 TEST SUMMARY")
|
||||
print("="*60)
|
||||
|
||||
passed = sum(1 for _, result in results if result)
|
||||
total = len(results)
|
||||
|
||||
for name, result in results:
|
||||
status = "✅ PASSED" if result else "❌ FAILED"
|
||||
print(f"{status}: {name}")
|
||||
|
||||
print(f"\nTotal: {passed}/{total} tests passed")
|
||||
|
||||
if passed == total:
|
||||
print("\n🎉 ALL TESTS PASSED! The watchlist system is ready to use.")
|
||||
return True
|
||||
else:
|
||||
print("\n⚠️ Some tests failed. Please review the errors above.")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = asyncio.run(run_all_tests())
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test script for the watchlist & auto-download system
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from app.watchlist import watchlist_manager
|
||||
from app.episode_checker import episode_checker
|
||||
from app.auto_download_scheduler import auto_download_scheduler
|
||||
from app.models.watchlist import (
|
||||
WatchlistItemCreate,
|
||||
WatchlistStatus,
|
||||
QualityPreference,
|
||||
WatchlistSettings
|
||||
)
|
||||
|
||||
|
||||
def test_watchlist_basics():
|
||||
"""Test basic watchlist operations"""
|
||||
print("\n" + "="*60)
|
||||
print("🧪 TEST 1: Watchlist Manager Basics")
|
||||
print("="*60)
|
||||
|
||||
# Test user ID
|
||||
test_user = "test_user_simple"
|
||||
|
||||
# Clean up any existing test items first
|
||||
print("\n0. Cleaning up any existing test items...")
|
||||
all_items = watchlist_manager.get_all()
|
||||
for item in all_items:
|
||||
if item.user_id == test_user:
|
||||
watchlist_manager.delete(item.id)
|
||||
print(f" ✓ Deleted old test item: {item.id}")
|
||||
|
||||
# Create a test item
|
||||
print("\n1. Creating watchlist item...")
|
||||
item_data = WatchlistItemCreate(
|
||||
anime_title="Test Anime Simple",
|
||||
anime_url="https://anime-sama.si/catalogue/test-simple/vostfr/",
|
||||
provider_id="animesama",
|
||||
lang="vostfr",
|
||||
auto_download=True,
|
||||
quality_preference=QualityPreference.AUTO
|
||||
)
|
||||
|
||||
try:
|
||||
item = watchlist_manager.create(test_user, item_data)
|
||||
print(f" ✅ Item created: {item.id}")
|
||||
print(f" Title: {item.anime_title}")
|
||||
print(f" Status: {item.status}")
|
||||
print(f" Auto-download: {item.auto_download}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Create failed: {e}")
|
||||
return False
|
||||
|
||||
# Get all items for user
|
||||
print("\n2. Getting user's items...")
|
||||
try:
|
||||
items = watchlist_manager.get_all(test_user)
|
||||
print(f" ✅ Found {len(items)} items for user")
|
||||
if len(items) > 0:
|
||||
print(f" First item: {items[0].anime_title}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Get all failed: {e}")
|
||||
return False
|
||||
|
||||
# Get stats
|
||||
print("\n3. Getting statistics...")
|
||||
try:
|
||||
stats = watchlist_manager.get_stats(test_user)
|
||||
print(f" ✅ Stats: total={stats['total']}, active={stats['active']}, paused={stats['paused']}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Stats failed: {e}")
|
||||
return False
|
||||
|
||||
# Update item
|
||||
print("\n4. Updating item to paused...")
|
||||
try:
|
||||
updated = watchlist_manager.update(item.id, {"status": WatchlistStatus.PAUSED})
|
||||
print(f" ✅ Item updated to status: {updated.status}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Update failed: {e}")
|
||||
return False
|
||||
|
||||
# Update check time
|
||||
print("\n5. Updating check time...")
|
||||
try:
|
||||
updated = watchlist_manager.update_check_time(item.id, 5)
|
||||
print(f" ✅ Check time updated")
|
||||
print(f" Last episode: {updated.last_episode_downloaded}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Update check time failed: {e}")
|
||||
return False
|
||||
|
||||
# Delete item (cleanup)
|
||||
print("\n6. Cleaning up - deleting test item...")
|
||||
try:
|
||||
result = watchlist_manager.delete(item.id)
|
||||
print(f" ✅ Item deleted: {result}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Delete failed: {e}")
|
||||
return False
|
||||
|
||||
print("\n✅ Watchlist Manager tests PASSED")
|
||||
return True
|
||||
|
||||
|
||||
def test_settings():
|
||||
"""Test settings management"""
|
||||
print("\n" + "="*60)
|
||||
print("🧪 TEST 2: Settings Management")
|
||||
print("="*60)
|
||||
|
||||
print("\n1. Getting current settings...")
|
||||
try:
|
||||
settings = watchlist_manager.get_settings()
|
||||
print(f" ✅ Current settings:")
|
||||
print(f" - Check interval: {settings.check_interval_hours}h")
|
||||
print(f" - Auto-download: {settings.auto_download_enabled}")
|
||||
print(f" - Max concurrent: {settings.max_concurrent_auto_downloads}")
|
||||
print(f" - Notifications: {settings.notify_on_new_episodes}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Get settings failed: {e}")
|
||||
return False
|
||||
|
||||
print("\n2. Updating settings...")
|
||||
try:
|
||||
new_settings = WatchlistSettings(
|
||||
check_interval_hours=12,
|
||||
auto_download_enabled=True,
|
||||
max_concurrent_auto_downloads=3,
|
||||
notify_on_new_episodes=False
|
||||
)
|
||||
watchlist_manager.update_settings(new_settings)
|
||||
print(f" ✅ Settings updated")
|
||||
except Exception as e:
|
||||
print(f" ❌ Update settings failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
print("\n3. Verifying settings...")
|
||||
try:
|
||||
settings = watchlist_manager.get_settings()
|
||||
if settings.check_interval_hours != 12:
|
||||
print(f" ❌ Settings not saved correctly: check_interval is {settings.check_interval_hours}, expected 12")
|
||||
return False
|
||||
|
||||
print(f" ✅ Settings verified:")
|
||||
print(f" - Check interval: {settings.check_interval_hours}h ✓")
|
||||
except Exception as e:
|
||||
print(f" ❌ Verify settings failed: {e}")
|
||||
return False
|
||||
|
||||
# Reset to defaults
|
||||
print("\n4. Resetting to default settings...")
|
||||
try:
|
||||
default_settings = WatchlistSettings()
|
||||
watchlist_manager.update_settings(default_settings)
|
||||
print(f" ✅ Settings reset to defaults")
|
||||
except Exception as e:
|
||||
print(f" ❌ Reset settings failed: {e}")
|
||||
return False
|
||||
|
||||
print("\n✅ Settings tests PASSED")
|
||||
return True
|
||||
|
||||
|
||||
async def test_scheduler():
|
||||
"""Test scheduler controls"""
|
||||
print("\n" + "="*60)
|
||||
print("🧪 TEST 3: Auto-Download Scheduler")
|
||||
print("="*60)
|
||||
|
||||
print("\n1. Testing scheduler start (async)...")
|
||||
try:
|
||||
auto_download_scheduler.start()
|
||||
print(f" ✅ Scheduler started")
|
||||
print(f" Status: running={auto_download_scheduler.is_running()}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Start failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
if not auto_download_scheduler.is_running():
|
||||
print(f" ❌ Scheduler not running after start")
|
||||
return False
|
||||
|
||||
print("\n2. Testing scheduler stop...")
|
||||
try:
|
||||
auto_download_scheduler.stop()
|
||||
print(f" ✅ Scheduler stopped")
|
||||
print(f" Status: running={auto_download_scheduler.is_running()}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Stop failed: {e}")
|
||||
return False
|
||||
|
||||
if auto_download_scheduler.is_running():
|
||||
print(f" ❌ Scheduler still running after stop")
|
||||
return False
|
||||
|
||||
print("\n3. Testing scheduler restart...")
|
||||
try:
|
||||
auto_download_scheduler.start()
|
||||
print(f" ✅ Scheduler restarted")
|
||||
|
||||
# Get next run time
|
||||
next_run = auto_download_scheduler.get_next_run_time()
|
||||
if next_run:
|
||||
print(f" Next run: {next_run}")
|
||||
|
||||
auto_download_scheduler.stop()
|
||||
print(f" ✅ Scheduler stopped again")
|
||||
except Exception as e:
|
||||
print(f" ❌ Restart failed: {e}")
|
||||
return False
|
||||
|
||||
print("\n✅ Scheduler tests PASSED")
|
||||
return True
|
||||
|
||||
|
||||
async def run_all_tests():
|
||||
"""Run all tests"""
|
||||
print("\n" + "="*60)
|
||||
print("🚀 WATCHLIST SYSTEM TEST SUITE")
|
||||
print("="*60)
|
||||
|
||||
tests = [
|
||||
("Watchlist Manager", lambda: test_watchlist_basics()),
|
||||
("Settings", lambda: test_settings()),
|
||||
("Scheduler", test_scheduler) # This one is async
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for name, test_func in tests:
|
||||
try:
|
||||
# Check if it's a coroutine function
|
||||
import asyncio
|
||||
if asyncio.iscoroutinefunction(test_func):
|
||||
result = await test_func()
|
||||
else:
|
||||
result = test_func()
|
||||
results.append((name, result))
|
||||
except Exception as e:
|
||||
print(f"\n❌ {name} test crashed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((name, False))
|
||||
|
||||
# Summary
|
||||
print("\n" + "="*60)
|
||||
print("📊 TEST SUMMARY")
|
||||
print("="*60)
|
||||
|
||||
passed = sum(1 for _, result in results if result)
|
||||
total = len(results)
|
||||
|
||||
for name, result in results:
|
||||
status = "✅ PASSED" if result else "❌ FAILED"
|
||||
print(f"{status}: {name}")
|
||||
|
||||
print(f"\nTotal: {passed}/{total} tests passed")
|
||||
|
||||
if passed == total:
|
||||
print("\n🎉 ALL TESTS PASSED! The watchlist system is ready to use.")
|
||||
print("\n📝 Next steps:")
|
||||
print(" 1. Start the server: uvicorn main:app --reload")
|
||||
print(" 2. Open http://localhost:3000/watchlist")
|
||||
print(" 3. Add anime to your watchlist")
|
||||
print(" 4. Start the scheduler")
|
||||
return True
|
||||
else:
|
||||
print("\n⚠️ Some tests failed. Please review the errors above.")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = asyncio.run(run_all_tests())
|
||||
sys.exit(0 if success else 1)
|
||||
Reference in New Issue
Block a user