c6be191699
## 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 <[email protected]>
Co-Authored-By: Happy <[email protected]>
437 lines
17 KiB
JavaScript
437 lines
17 KiB
JavaScript
/**
|
|
* Anime search and episode management
|
|
*/
|
|
|
|
/**
|
|
* Display search results
|
|
*/
|
|
async function displaySearchResults(data, lang) {
|
|
const resultsContainer = document.getElementById('searchResults');
|
|
const providers = await getProvidersInfo();
|
|
|
|
let totalResults = 0;
|
|
let htmlPromises = [];
|
|
|
|
for (const [providerId, results] of Object.entries(data.results)) {
|
|
if (results && results.length > 0) {
|
|
totalResults += results.length;
|
|
|
|
results.forEach(anime => {
|
|
const providerInfo = providers.anime_providers[providerId];
|
|
// Collect promises for async rendering
|
|
htmlPromises.push(renderAnimeCard(anime, providerId, providerInfo, lang));
|
|
});
|
|
}
|
|
}
|
|
|
|
if (totalResults === 0) {
|
|
resultsContainer.innerHTML = '<div class="no-results">Aucun résultat trouvé</div>';
|
|
return;
|
|
}
|
|
|
|
// Wait for all cards to be rendered
|
|
const htmlSegments = await Promise.all(htmlPromises);
|
|
resultsContainer.innerHTML = htmlSegments.join('');
|
|
|
|
// Auto-load seasons for providers that support them
|
|
// Stagger the requests to avoid overwhelming the server
|
|
let delayCounter = 0;
|
|
for (const [providerId, results] of Object.entries(data.results)) {
|
|
if (results && results.length > 0) {
|
|
results.forEach((anime, index) => {
|
|
// Stagger requests: 500ms delay between each anime
|
|
setTimeout(() => {
|
|
// Try to load seasons first (if provider supports them)
|
|
if (anime.url) {
|
|
loadSeasonsForAnime(providerId, encodeURIComponent(anime.url));
|
|
}
|
|
}, 500 * index);
|
|
delayCounter++;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render anime card HTML
|
|
*/
|
|
async function renderAnimeCard(anime, providerId, providerInfo, lang) {
|
|
const metadataHtml = renderAnimeMetadata(anime.metadata);
|
|
|
|
// Check if provider supports seasons using helper function
|
|
const supportsSeasons = await providerSupportsSeasons(providerId, anime.url);
|
|
|
|
const seasonSelectHtml = supportsSeasons ? `
|
|
<select id="seasons-${providerId}-${encodeURIComponent(anime.url)}" onchange="handleSeasonChange('${providerId}', '${encodeURIComponent(anime.url)}', '${lang}')" style="margin-bottom: 8px;">
|
|
<option value="">Chargement des saisons...</option>
|
|
</select>
|
|
` : '';
|
|
|
|
return `
|
|
<div class="anime-card" id="anime-${providerId}-${encodeURIComponent(anime.url)}">
|
|
<div class="anime-card-header">
|
|
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
|
<div class="anime-card-provider">${providerInfo?.icon || ''} ${providerInfo?.name || providerId}</div>
|
|
</div>
|
|
${metadataHtml}
|
|
<div class="anime-card-actions">
|
|
${seasonSelectHtml}
|
|
<select id="episodes-${providerId}-${encodeURIComponent(anime.url)}">
|
|
<option value="">${supportsSeasons ? 'Sélectionner une saison d\'abord' : 'Charger les épisodes...'}</option>
|
|
</select>
|
|
</div>
|
|
<div class="anime-card-actions" id="actions-${providerId}-${encodeURIComponent(anime.url)}" style="display:none;">
|
|
<button class="btn-primary" onclick="handleDownloadEpisode('${encodeURIComponent(anime.url)}', '${providerId}', '${lang}')">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
|
</svg>
|
|
Télécharger
|
|
</button>
|
|
<button class="btn-primary" style="background: linear-gradient(45deg, #ff6b6b, #ffa500);" onclick="handleDownloadSeason('${encodeURIComponent(anime.url)}', '${lang}')" title="Télécharger toute la saison">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
|
</svg>
|
|
Toute la saison
|
|
</button>
|
|
<button class="btn-secondary" onclick="handleAddToWatchlist('${encodeURIComponent(anime.url)}', '${providerId}')"
|
|
data-watchlist-url="${encodeURIComponent(anime.url)}"
|
|
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; padding: 6px 16px; font-size: 13px; border-radius: 6px; cursor: pointer; transition: all 0.2s;"
|
|
onmouseover="this.style.transform='scale(1.05)'"
|
|
onmouseout="this.style.transform='scale(1)'">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;vertical-align:middle;">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364 0z"></path>
|
|
</svg>
|
|
<span style="font-weight:500;">+ Suivre</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Render anime metadata
|
|
*/
|
|
function renderAnimeMetadata(metadata) {
|
|
if (!metadata) return '';
|
|
|
|
let metaParts = [];
|
|
|
|
if (metadata.release_year) metaParts.push(`📅 ${metadata.release_year}`);
|
|
if (metadata.rating) metaParts.push(`⭐ ${metadata.rating}`);
|
|
if (metadata.genres && metadata.genres.length > 0) metaParts.push(`🏷️ ${metadata.genres.slice(0, 3).join(', ')}`);
|
|
if (metadata.total_episodes) metaParts.push(`📺 ${metadata.total_episodes} épisodes`);
|
|
if (metadata.status) metaParts.push(`📡 ${metadata.status === 'Ongoing' ? 'En cours' : 'Terminé'}`);
|
|
|
|
let html = '';
|
|
|
|
if (metaParts.length > 0) {
|
|
html += `
|
|
<div class="anime-metadata">
|
|
${metaParts.join(' • ')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (metadata.synopsis) {
|
|
html += `
|
|
<details class="anime-synopsis">
|
|
<summary>📖 Synopsis</summary>
|
|
<p>${escapeHtml(metadata.synopsis)}</p>
|
|
</details>
|
|
`;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Load seasons for anime (if provider supports it)
|
|
*/
|
|
async function loadSeasonsForAnime(providerId, encodedUrl) {
|
|
const url = decodeURIComponent(encodedUrl);
|
|
const seasonSelectId = `seasons-${providerId}-${encodedUrl}`;
|
|
|
|
const seasonSelectElement = document.getElementById(seasonSelectId);
|
|
if (!seasonSelectElement) return;
|
|
|
|
// Check if provider supports seasons
|
|
const supportsSeasons = await providerSupportsSeasons(providerId, url);
|
|
if (!supportsSeasons) {
|
|
seasonSelectElement.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Mark as loading to prevent duplicate requests
|
|
if (seasonSelectElement.dataset.loading === 'true') {
|
|
console.log('Season loading already in progress, skipping...');
|
|
return;
|
|
}
|
|
seasonSelectElement.dataset.loading = 'true';
|
|
|
|
try {
|
|
// Add timeout to the fetch
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 second timeout
|
|
|
|
const response = await fetch(`${API_BASE}/anime/seasons?url=${encodeURIComponent(url)}`, {
|
|
signal: controller.signal
|
|
});
|
|
clearTimeout(timeoutId);
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
if (data.seasons && data.seasons.length > 0) {
|
|
seasonSelectElement.innerHTML = '<option value="">Sélectionner une saison</option>';
|
|
|
|
data.seasons.forEach(season => {
|
|
const option = document.createElement('option');
|
|
option.value = season.url;
|
|
const episodeText = season.episode_count ?
|
|
`${season.episode_count} épisodes` :
|
|
'Chargement...';
|
|
option.textContent = `${season.title} (${episodeText})`;
|
|
option.dataset.seasonNum = season.season;
|
|
seasonSelectElement.appendChild(option);
|
|
});
|
|
|
|
console.log(`Loaded ${data.seasons.length} seasons`);
|
|
} else {
|
|
// No seasons found, hide season selector and load episodes directly
|
|
seasonSelectElement.style.display = 'none';
|
|
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
|
|
}
|
|
} else {
|
|
console.error('Failed to load seasons:', response.status);
|
|
seasonSelectElement.style.display = 'none';
|
|
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
|
|
}
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
console.error('Season loading timeout');
|
|
seasonSelectElement.innerHTML = '<option value="">⏱️ Timeout - Réessayez</option>';
|
|
// Add retry functionality
|
|
seasonSelectElement.disabled = false;
|
|
seasonSelectElement.onclick = () => {
|
|
seasonSelectElement.dataset.loading = 'false';
|
|
seasonSelectElement.onclick = null;
|
|
loadSeasonsForAnime(providerId, encodedUrl);
|
|
};
|
|
} else {
|
|
console.error('Error loading seasons:', error);
|
|
seasonSelectElement.style.display = 'none';
|
|
loadEpisodesForAnime(providerId, encodedUrl, 'vostfr');
|
|
}
|
|
} finally {
|
|
seasonSelectElement.dataset.loading = 'false';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle season selection change
|
|
*/
|
|
async function handleSeasonChange(providerId, encodedUrl, lang) {
|
|
const seasonSelectId = `seasons-${providerId}-${encodedUrl}`;
|
|
const seasonSelectElement = document.getElementById(seasonSelectId);
|
|
|
|
const selectedSeasonUrl = seasonSelectElement.value;
|
|
const encodedSeasonUrl = encodeURIComponent(selectedSeasonUrl);
|
|
|
|
if (!selectedSeasonUrl) {
|
|
// Clear episodes if no season selected
|
|
const episodeSelectId = `episodes-${providerId}-${encodedUrl}`;
|
|
const episodeSelectElement = document.getElementById(episodeSelectId);
|
|
episodeSelectElement.innerHTML = '<option value="">Sélectionner une saison d\'abord</option>';
|
|
episodeSelectElement.disabled = true;
|
|
return;
|
|
}
|
|
|
|
// Find the episode select element (it's based on the original anime URL)
|
|
const episodeSelectId = `episodes-${providerId}-${encodedUrl}`;
|
|
const selectElement = document.getElementById(episodeSelectId);
|
|
|
|
if (!selectElement) {
|
|
console.error('Episode select element not found:', episodeSelectId);
|
|
return;
|
|
}
|
|
|
|
// Show loading state
|
|
selectElement.innerHTML = '<option value="">Chargement...</option>';
|
|
selectElement.disabled = false;
|
|
|
|
try {
|
|
// Load episodes for the selected season
|
|
const data = await loadEpisodes(selectedSeasonUrl, lang);
|
|
|
|
if (data.episodes && data.episodes.length > 0) {
|
|
selectElement.innerHTML = '<option value="">Sélectionner un épisode</option>';
|
|
data.episodes.forEach(ep => {
|
|
const option = document.createElement('option');
|
|
option.value = ep.url;
|
|
option.textContent = `Épisode ${ep.episode}`;
|
|
selectElement.appendChild(option);
|
|
});
|
|
|
|
// Show download buttons
|
|
const actionsId = `actions-${providerId}-${encodedUrl}`;
|
|
const actionsDiv = document.getElementById(actionsId);
|
|
actionsDiv.style.display = 'flex';
|
|
} else {
|
|
selectElement.innerHTML = '<option value="">Aucun épisode disponible</option>';
|
|
selectElement.disabled = true;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading episodes:', error);
|
|
selectElement.innerHTML = '<option value="">Erreur de chargement</option>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load episodes for an anime
|
|
*/
|
|
async function loadEpisodesForAnime(providerId, encodedUrl, lang) {
|
|
const url = decodeURIComponent(encodedUrl);
|
|
const selectId = `episodes-${providerId}-${encodedUrl}`;
|
|
const actionsId = `actions-${providerId}-${encodedUrl}`;
|
|
|
|
const selectElement = document.getElementById(selectId);
|
|
if (!selectElement) return;
|
|
|
|
selectElement.innerHTML = '<option value="">Chargement...</option>';
|
|
|
|
try {
|
|
const data = await loadEpisodes(url, lang);
|
|
|
|
if (data.episodes && data.episodes.length > 0) {
|
|
selectElement.innerHTML = '<option value="">Sélectionner un épisode</option>';
|
|
data.episodes.forEach(ep => {
|
|
const option = document.createElement('option');
|
|
option.value = ep.url;
|
|
option.textContent = `Épisode ${ep.episode}`;
|
|
selectElement.appendChild(option);
|
|
});
|
|
|
|
// Show download buttons
|
|
const actionsDiv = document.getElementById(actionsId);
|
|
actionsDiv.style.display = 'flex';
|
|
} else {
|
|
selectElement.innerHTML = '<option value="">Aucun épisode disponible</option>';
|
|
selectElement.disabled = true;
|
|
|
|
// Add warning message
|
|
const card = document.getElementById(`anime-${providerId}-${encodedUrl}`);
|
|
if (card) {
|
|
const warning = document.createElement('div');
|
|
warning.style.cssText = 'margin-top: 10px; padding: 10px; background: rgba(255, 100, 100, 0.2); border-radius: 6px; font-size: 12px; color: #ff6b6b;';
|
|
warning.textContent = '⚠️ Aucun épisode trouvé. Essayez une recherche exacte ou un autre fournisseur.';
|
|
card.appendChild(warning);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading episodes:', error);
|
|
selectElement.innerHTML = '<option value="">Erreur de chargement</option>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle episode download
|
|
*/
|
|
async function handleDownloadEpisode(encodedUrl, providerId, lang) {
|
|
const url = decodeURIComponent(encodedUrl);
|
|
const selectId = `episodes-${providerId}-${encodedUrl}`;
|
|
const selectElement = document.getElementById(selectId);
|
|
|
|
const episodeUrl = selectElement.value;
|
|
if (!episodeUrl) {
|
|
alert('Veuillez sélectionner un épisode');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await downloadEpisode(episodeUrl);
|
|
loadDownloads();
|
|
alert('Téléchargement démarré!');
|
|
selectElement.value = '';
|
|
} catch (error) {
|
|
console.error('Download error:', error);
|
|
alert('Erreur lors du démarrage du téléchargement');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle season download
|
|
*/
|
|
async function handleDownloadSeason(encodedUrl, lang) {
|
|
const url = decodeURIComponent(encodedUrl);
|
|
|
|
if (!confirm(`⚠️ Attention: Vous allez télécharger toute la saison. Cela peut prendre du temps et utiliser beaucoup d'espace disque.\n\nVoulez-vous continuer ?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = await downloadSeason(url, lang);
|
|
loadDownloads();
|
|
alert(`✅ ${data.message}\n\n${data.total_episodes} épisodes ont été ajoutés à la file de téléchargement!`);
|
|
} catch (error) {
|
|
console.error('Season download error:', error);
|
|
alert('Erreur lors du démarrage du téléchargement de la saison');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle search form submission
|
|
*/
|
|
async function handleSearch() {
|
|
const query = document.getElementById('searchInput').value.trim();
|
|
|
|
if (!query) return;
|
|
|
|
// Use the new anime details search
|
|
await searchAnimeDetails(query);
|
|
}
|
|
|
|
// Handle anime search (new dedicated function)
|
|
async function handleAnimeSearch() {
|
|
const searchInput = document.getElementById('animeSearchInput') || document.getElementById('searchInput');
|
|
if (!searchInput) return;
|
|
|
|
const query = searchInput.value.trim();
|
|
if (!query) return;
|
|
|
|
// Use the new anime details search
|
|
await searchAnimeDetails(query);
|
|
}
|
|
|
|
// Ensure global scope
|
|
window.handleSearch = handleSearch;
|
|
window.handleAnimeSearch = handleAnimeSearch;
|
|
|
|
/**
|
|
* Handle direct download form submission
|
|
*/
|
|
async function handleDirectDownload(e) {
|
|
e.preventDefault();
|
|
const url = document.getElementById('urlInput').value;
|
|
|
|
try {
|
|
await startDownload(url);
|
|
document.getElementById('urlInput').value = '';
|
|
loadDownloads();
|
|
} catch (error) {
|
|
console.error('Download error:', error);
|
|
alert('Erreur lors du démarrage du téléchargement');
|
|
}
|
|
}
|
|
|
|
// Ensure all functions are globally accessible
|
|
window.displaySearchResults = displaySearchResults;
|
|
window.renderAnimeCard = renderAnimeCard;
|
|
window.renderAnimeMetadata = renderAnimeMetadata;
|
|
window.loadSeasonsForAnime = loadSeasonsForAnime;
|
|
window.handleSeasonChange = handleSeasonChange;
|
|
window.loadEpisodesForAnime = loadEpisodesForAnime;
|
|
window.handleDownloadEpisode = handleDownloadEpisode;
|
|
window.handleDownloadSeason = handleDownloadSeason;
|
|
window.handleSearch = handleSearch;
|
|
window.handleDirectDownload = handleDirectDownload;
|