Files
ohm_streaming/static/js/tabs.js
T
root c6be191699 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 <[email protected]>
Co-Authored-By: Happy <[email protected]>
2026-01-29 21:56:39 +00:00

404 lines
16 KiB
JavaScript

/**
* New tabs functionality
*/
// Render series recommendation card (same design as anime recommendations)
function renderSeriesRecommendationCard(series) {
let coverImage = series.cover_image || '';
// Convert relative poster.php URLs to absolute URLs
if (coverImage.startsWith('/poster.php?url=')) {
// Extract the actual image URL from the poster.php URL
const actualUrl = coverImage.replace('/poster.php?url=', '');
coverImage = actualUrl;
}
// If it's a relative path, make it absolute using FS7 base URL
else if (coverImage.startsWith('/')) {
coverImage = 'https://fs7.lol' + coverImage;
}
return `
<div class="anime-card-horizontal recommendation-card">
<div class="recommendation-badge">🎺 Série TV populaire</div>
<div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(series.title)}</div>
</div>
<div class="anime-card-content">
${coverImage ? `<img src="${escapeHtml(coverImage)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
<div class="anime-card-info">
<div class="anime-card-meta">
📺 Série TV
</div>
</div>
</div>
<div class="anime-card-actions">
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
🔗 Voir sur FS7
</button>
<button class="btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
📥 Voir les épisodes
</button>
</div>
</div>
`;
}
// Load series episodes (redirects to series tab with search)
async function loadSeriesEpisodes(url, title) {
// Switch to series tab
switchTab('series');
// Fill search input with the series title
const searchInput = document.getElementById('seriesSearchInput');
if (searchInput) {
searchInput.value = title;
// Trigger search
setTimeout(() => {
if (typeof handleSeriesSearch === 'function') {
handleSeriesSearch();
}
}, 300);
}
}
// Render series release card (same design as anime releases)
function renderSeriesReleaseCard(series) {
let coverImage = series.cover_image || '';
// Convert relative poster.php URLs to absolute URLs
if (coverImage.startsWith('/poster.php?url=')) {
// Extract the actual image URL from the poster.php URL
const actualUrl = coverImage.replace('/poster.php?url=', '');
coverImage = actualUrl;
}
// If it's a relative path, make it absolute using FS7 base URL
else if (coverImage.startsWith('/')) {
coverImage = 'https://fs7.lol' + coverImage;
}
return `
<div class="anime-card-horizontal release-card">
<div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(series.title)}</div>
</div>
<div class="anime-card-content">
${coverImage ? `<img src="${escapeHtml(coverImage)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
<div class="anime-card-info">
<div class="anime-card-meta">
📺 Série TV • Nouveau
</div>
</div>
</div>
<div class="anime-card-actions">
<button class="btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
🔗 Voir sur FS7
</button>
<button class="btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
📥 Voir les épisodes
</button>
</div>
</div>
`;
}
// Load series recommendations for the Series tab
async function loadSeriesRecommendations() {
try {
const container = document.getElementById('seriesRecommendationsList');
if (!container) return;
container.innerHTML = '<div class="loading-spinner">Chargement des recommandations séries...</div>';
// Search for popular series from all providers (including FS7)
const searchTerms = ['Breaking Bad', 'Game of Thrones', 'Stranger Things', 'The Walking Dead', 'The Last of Us', 'Wednesday', 'Squid Game', 'Peaky Blinders', 'House of the Dragon', 'The Witcher'];
const allSeries = [];
for (const term of searchTerms) {
try {
const response = await fetch(`${API_BASE}/series/search?q=${encodeURIComponent(term)}&lang=vf`);
const data = await response.json();
// Collect results from all providers, especially fs7
if (data.results) {
// Prioritize fs7 results
if (data.results['fs7'] && data.results['fs7'].length > 0) {
allSeries.push(...data.results['fs7'].slice(0, 2));
}
}
} catch (error) {
console.warn(`Error searching for ${term}:`, error);
}
if (allSeries.length >= 12) break; // Limit to 12 series total
}
if (allSeries.length > 0) {
container.innerHTML = `<div class="recommendations-carousel">${allSeries.map(series =>
renderSeriesRecommendationCard(series)
).join('')}</div>`;
} else {
container.innerHTML = '<div class="no-results">Aucune recommandation trouvée</div>';
}
} catch (error) {
console.error('Error loading series recommendations:', error);
const container = document.getElementById('seriesRecommendationsList');
if (container) container.innerHTML = '<div class="no-results">Erreur lors du chargement</div>';
}
}
// Load anime releases for the Anime tab
async function loadAnimeReleases() {
try {
const container = document.getElementById('animeReleasesList');
if (!container) return;
container.innerHTML = '<div class="loading-spinner">Chargement des dernières sorties anime...</div>';
// Use the existing releases API
const response = await fetch(`${API_BASE}/releases/latest?limit=12`);
const data = await response.json();
if (data.releases && data.releases.length > 0) {
container.innerHTML = `<div class="recommendations-carousel">${data.releases.map(anime =>
renderReleaseCard(anime)
).join('')}</div>`;
} else {
container.innerHTML = '<div class="no-results">Aucune sortie trouvée</div>';
}
} catch (error) {
console.error('Error loading anime releases:', error);
const container = document.getElementById('animeReleasesList');
if (container) container.innerHTML = '<div class="no-results">Erreur lors du chargement</div>';
}
}
// Load series releases for the Series tab
async function loadSeriesReleases() {
try {
const container = document.getElementById('seriesReleasesList');
if (!container) return;
container.innerHTML = '<div class="loading-spinner">Chargement des dernières séries TV...</div>';
// Search for popular series from all providers (including FS7)
const searchTerms = ['Breaking Bad', 'Game of Thrones', 'Stranger Things', 'The Walking Dead', 'The Last of Us', 'Wednesday', 'Squid Game', 'Peaky Blinders'];
const allSeries = [];
for (const term of searchTerms) {
try {
const response = await fetch(`${API_BASE}/series/search?q=${encodeURIComponent(term)}&lang=vf`);
const data = await response.json();
// Collect results from all providers, especially fs7
if (data.results) {
// Prioritize fs7 results
if (data.results['fs7'] && data.results['fs7'].length > 0) {
allSeries.push(...data.results['fs7'].slice(0, 2));
}
// Add results from other providers if needed
for (const [provider, results] of Object.entries(data.results)) {
if (provider !== 'fs7' && results.length > 0 && allSeries.length < 12) {
allSeries.push(...results.slice(0, 1));
}
}
}
} catch (error) {
console.warn(`Error searching for ${term}:`, error);
}
if (allSeries.length >= 12) break; // Limit to 12 series total
}
if (allSeries.length > 0) {
container.innerHTML = `<div class="releases-carousel">${allSeries.map(series =>
renderSeriesReleaseCard(series)
).join('')}</div>`;
} else {
container.innerHTML = `
<div class="no-results">
<p>Aucune série trouvée</p>
<p style="font-size: 12px; margin-top: 10px; opacity: 0.7;">
Utilisez l'onglet "Recherche" pour trouver des séries spécifiques
</p>
</div>`;
}
} catch (error) {
console.error('Error loading series releases:', error);
const container = document.getElementById('seriesReleasesList');
if (container) {
container.innerHTML = `
<div class="no-results">
<p>❌ Erreur lors du chargement des séries</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
<button class="btn-secondary btn-small" onclick="loadSeriesReleases()" style="margin-top: 10px;">
🔄 Réessayer
</button>
</div>`;
}
}
}
// Load providers grid for the Providers tab
async function loadProvidersGrid() {
try {
const container = document.getElementById('providersGrid');
if (!container) return;
container.innerHTML = '<div class="loading-spinner">Chargement des fournisseurs...</div>';
const response = await fetch(`${API_BASE}/providers`);
const data = await response.json();
let html = '';
// Section Anime providers
html += '<div class="section-header"><h3 style="margin-top: 20px;">🎬 Sites Anime</h3></div>';
html += '<div class="search-results">';
const animeProviders = Object.entries(data.anime_providers || {});
if (animeProviders.length > 0) {
animeProviders.forEach(([id, provider]) => {
const domains = provider.domains || [];
html += `
<div class="anime-card">
<div class="anime-card-header">
<div class="anime-card-title">${provider.icon} ${provider.name}</div>
</div>
${domains.length > 0 ? `
<div class="anime-metadata" style="margin-bottom: 12px;">
<strong>Domaines:</strong><br>
${domains.map(d => `<code style="background: rgba(0,217,255,0.1); padding: 2px 6px; border-radius: 4px; margin-right: 4px;">${d}</code>`).join('')}
</div>
` : ''}
<div class="anime-card-actions">
${domains.length > 0 ? `
<button class="btn-primary btn-small" onclick="window.open('https://${domains[0]}', '_blank')">
🔗 Visiter le site
</button>
` : ''}
<button class="btn-secondary btn-small" onclick="showProviderSearch('${id}')">
🔍 Rechercher
</button>
</div>
</div>
`;
});
} else {
html += '<div class="no-results">Aucun fournisseur anime disponible</div>';
}
html += '</div>';
// Section File hosts
html += '<div class="section-header" style="margin-top: 40px;"><h3>💾 Hébergeurs de fichiers</h3></div>';
html += '<div class="search-results">';
const fileHosts = Object.entries(data.file_hosts || {});
if (fileHosts.length > 0) {
fileHosts.forEach(([id, host]) => {
html += `
<div class="anime-card">
<div class="anime-card-header">
<div class="anime-card-title">${host.icon} ${host.name}</div>
</div>
<div class="anime-card-actions">
<button class="btn-secondary btn-small" onclick="showDownloadInfo()">
📥 Télécharger un fichier
</button>
</div>
</div>
`;
});
} else {
html += '<div class="no-results">Aucun hébergeur disponible</div>';
}
html += '</div>';
container.innerHTML = html;
} catch (error) {
console.error('Error loading providers:', error);
const container = document.getElementById('providersGrid');
if (container) {
container.innerHTML = `
<div class="no-results">
<p>❌ Erreur lors du chargement des fournisseurs</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
<button class="btn-secondary btn-small" onclick="loadProvidersGrid()" style="margin-top: 10px;">
🔄 Réessayer
</button>
</div>
`;
}
}
}
// Show provider search (redirects to search tab)
function showProviderSearch(providerId) {
switchTab('search');
// Could pre-fill search with provider-specific content
}
// Show download info (explains how to download)
function showDownloadInfo() {
alert('💡 Pour télécharger un fichier:\n\n1. Utilisez l\'onglet "Recherche"\n2. Entrez le nom de l\'anime/série\n3. Cliquez sur "Télécharger" sur un épisode\n\nOu bien:\n- Copiez directement un lien de téléchargement dans la barre d\'adresse de votre navigateur');
}
// Make additional functions available globally
window.showProviderSearch = showProviderSearch;
window.showDownloadInfo = showDownloadInfo;
// Initialize new tabs when they're first opened
document.addEventListener('DOMContentLoaded', () => {
// Wait for main.js to be loaded
setTimeout(() => {
// Override switchTab to load content when opening new tabs
const originalSwitchTab = window.switchTab;
if (originalSwitchTab) {
window.switchTab = function(tabName) {
// Call original switchTab first
originalSwitchTab(tabName);
// Load content for new tabs (after a small delay for DOM to update)
setTimeout(() => {
if (tabName === 'anime') {
if (!window.animeTabLoaded) {
loadAnimeReleases();
window.animeTabLoaded = true;
}
} else if (tabName === 'series') {
if (!window.seriesTabLoaded) {
loadSeriesRecommendations();
loadSeriesReleases();
window.seriesTabLoaded = true;
}
} else if (tabName === 'providers') {
if (!window.providersTabLoaded) {
loadProvidersGrid();
window.providersTabLoaded = true;
}
} else if (tabName === 'watchlist') {
// Watchlist is handled by its own page
window.location.href = '/watchlist';
}
}, 100);
};
}
}, 500);
});
// Make functions available globally
window.loadSeriesEpisodes = loadSeriesEpisodes;
window.loadSeriesRecommendations = loadSeriesRecommendations;
window.loadAnimeReleases = loadAnimeReleases;
window.loadSeriesReleases = loadSeriesReleases;
window.loadProvidersGrid = loadProvidersGrid;