From c6be191699c94f27a5298609055da172df9aa84c Mon Sep 17 00:00:00 2001 From: root Date: Thu, 29 Jan 2026 21:56:39 +0000 Subject: [PATCH] feat: Complete watchlist & auto-download system with UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-Authored-By: Happy --- app/auto_download_scheduler.py | 1 + app/watchlist.py | 16 +- config/watchlist.json | 22 ++ config/watchlist_settings.json | 7 + main.py | 6 + static/js/anime.js | 10 + static/js/tabs.js | 3 + static/js/watchlist-ui.js | 331 +++++++++++++++++++ static/js/watchlist.js | 319 +++++++++++++++++++ templates/components/header.html | 6 + templates/watchlist.html | 531 +++++++++++++++++++++++++++++++ test_watchlist.py | 248 +++++++++++++++ test_watchlist_simple.py | 287 +++++++++++++++++ 13 files changed, 1784 insertions(+), 3 deletions(-) create mode 100644 config/watchlist.json create mode 100644 config/watchlist_settings.json create mode 100644 static/js/watchlist-ui.js create mode 100644 static/js/watchlist.js create mode 100644 templates/watchlist.html create mode 100644 test_watchlist.py create mode 100644 test_watchlist_simple.py diff --git a/app/auto_download_scheduler.py b/app/auto_download_scheduler.py index 76ed5dd..eda4fd0 100644 --- a/app/auto_download_scheduler.py +++ b/app/auto_download_scheduler.py @@ -1,4 +1,5 @@ """Scheduler for automatic episode checking and downloading""" +import asyncio import logging from datetime import datetime from typing import Optional diff --git a/app/watchlist.py b/app/watchlist.py index 37c2277..bf2c2af 100644 --- a/app/watchlist.py +++ b/app/watchlist.py @@ -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 + # 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 - update_dict = update_data.model_dump(exclude_unset=True) for field, value in update_dict.items(): if value is not None: setattr(item, field, value) diff --git a/config/watchlist.json b/config/watchlist.json new file mode 100644 index 0000000..c79695f --- /dev/null +++ b/config/watchlist.json @@ -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" + } +} \ No newline at end of file diff --git a/config/watchlist_settings.json b/config/watchlist_settings.json new file mode 100644 index 0000000..caa0063 --- /dev/null +++ b/config/watchlist_settings.json @@ -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 +} \ No newline at end of file diff --git a/main.py b/main.py index fca0f9a..d4e6921 100644 --- a/main.py +++ b/main.py @@ -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): diff --git a/static/js/anime.js b/static/js/anime.js index 647138e..a27c576 100644 --- a/static/js/anime.js +++ b/static/js/anime.js @@ -93,6 +93,16 @@ async function renderAnimeCard(anime, providerId, providerInfo, lang) { Toute la saison + `; diff --git a/static/js/tabs.js b/static/js/tabs.js index 80b8181..a720422 100644 --- a/static/js/tabs.js +++ b/static/js/tabs.js @@ -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); }; diff --git a/static/js/watchlist-ui.js b/static/js/watchlist-ui.js new file mode 100644 index 0000000..a36848b --- /dev/null +++ b/static/js/watchlist-ui.js @@ -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 = '
Chargement de la watchlist...
'; + + const items = await getWatchlist(status); + const stats = await getWatchlistStats(); + + if (items.length === 0) { + container.innerHTML = ` +
+
+ + + +

Aucun anime dans votre watchlist

+

Ajoutez des animes depuis la recherche pour commencer le suivi automatique

+
+
+ `; + return; + } + + // Render stats + let statsHtml = ''; + if (stats && stats.total > 0) { + statsHtml = ` +
+
+
${stats.total}
+
Total
+
+
+
${stats.active}
+
Actifs
+
+
+
${stats.paused}
+
En pause
+
+
+
${stats.completed}
+
Terminés
+
+
+ `; + } + + // Render items + let itemsHtml = ''; + items.forEach(item => { + const statusIcon = getStatusIcon(item.status); + const statusBadge = getStatusBadge(item.status); + const lastEpInfo = item.last_episode_downloaded > 0 + ? `Dernier épisode: ${item.last_episode_downloaded}` + : ''; + + itemsHtml += ` +
+
+
+
+

${escapeHtml(item.anime_title)}

+ ${statusBadge} +
+ +
+ ${statusIcon} ${item.provider_id} • ${item.lang.toUpperCase()} +
+ + ${lastEpInfo ? ` +
+ ${lastEpInfo} +
+ ` : ''} + + ${item.last_checked ? ` +
+ Dernière vérification: ${new Date(item.last_checked).toLocaleString('fr-FR')} +
+ ` : '
Jamais vérifié
'} +
+ +
+ ${item.status === 'active' && item.auto_download ? ` + + ` : item.status === 'paused' ? ` + + ` : ''} + + + + +
+
+ + ${item.synopsis ? ` +
+ 📖 Synopsis +

${escapeHtml(item.synopsis)}

+
+ ` : ''} +
+ `; + }); + + container.innerHTML = statsHtml + itemsHtml; + + } catch (error) { + console.error('Error loading watchlist:', error); + container.innerHTML = ` +
+ ❌ Erreur lors du chargement: ${error.message} +
+ `; + } +} + +/** + * Get status icon + */ +function getStatusIcon(status) { + const icons = { + 'active': '✅', + 'paused': '⏸️', + 'completed': '✨', + 'archived': '📦' + }; + return icons[status] || '📌'; +} + +/** + * Get status badge + */ +function getStatusBadge(status) { + const badges = { + 'active': 'Actif', + 'paused': 'En pause', + 'completed': 'Terminé', + 'archived': 'Archivé' + }; + 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; diff --git a/static/js/watchlist.js b/static/js/watchlist.js new file mode 100644 index 0000000..248edc7 --- /dev/null +++ b/static/js/watchlist.js @@ -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; diff --git a/templates/components/header.html b/templates/components/header.html index 2d4c1fe..3f3c618 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -44,5 +44,11 @@ Fournisseurs + diff --git a/templates/watchlist.html b/templates/watchlist.html new file mode 100644 index 0000000..ffdbcaf --- /dev/null +++ b/templates/watchlist.html @@ -0,0 +1,531 @@ + + + + + + Watchlist - Ohm Stream Downloader + + + + +
+ +
+

📋 Ma Watchlist

+

Suivez vos animes préférés et téléchargez automatiquement les nouveaux épisodes

+
+ + +
+
+
+

⏰ Planificateur Automatique

+
Chargement...
+
+
+ + + + +
+
+
+ + +
+ + + + +
+ + +
+
Chargement de la watchlist...
+
+
+ + + + + + + + + + diff --git a/test_watchlist.py b/test_watchlist.py new file mode 100644 index 0000000..27588b2 --- /dev/null +++ b/test_watchlist.py @@ -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) diff --git a/test_watchlist_simple.py b/test_watchlist_simple.py new file mode 100644 index 0000000..d2d691e --- /dev/null +++ b/test_watchlist_simple.py @@ -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)