feat: Complete watchlist & auto-download system with UI

Implement comprehensive watchlist system with automatic episode detection
and downloading. Features include per-user watchlists, scheduler-based
periodic checks, and a modern web UI.

**Backend Components:**
- WatchlistManager: JSON-based storage with multi-tenant support
- EpisodeChecker: Detects and downloads new episodes automatically
- AutoDownloadScheduler: APScheduler-based periodic task execution
- Complete REST API for CRUD operations and scheduler control

**Frontend Components:**
- Modern watchlist page with dark theme and animations
- Real-time status updates and progress tracking
- Scheduler controls with next-run display
- Add anime directly from search results

**Models & Configuration:**
- WatchlistItem with status, quality, and auto-download settings
- WatchlistSettings for global configuration
- Per-user statistics and provider tracking

**API Endpoints:**
- GET/POST /api/watchlist - List and add items
- PUT/DELETE /api/watchlist/{id} - Update and delete
- POST /api/watchlist/{id}/check - Manual check trigger
- POST /api/watchlist/check-all - Check all due items
- GET/PUT /api/watchlist/settings - Global settings
- GET /api/watchlist/stats - Statistics
- GET/POST /api/watchlist/scheduler/* - Scheduler control

**Configuration Files:**
- config/watchlist.json - User watchlist data
- config/watchlist_settings.json - Global settings

Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-02-24 09:13:22 +00:00
parent c6be191699
commit da5403a307
17 changed files with 1733 additions and 259 deletions
+185 -173
View File
@@ -32,7 +32,7 @@ from app.models.sonarr import (
SonarrDownloadRequest
)
from app.models.auth import UserCreate, UserLogin, User, Token
from app.auth import user_manager, create_access_token, verify_token, get_current_user
from app.auth import user_manager, create_access_token, verify_token
from app.utils import sanitize_filename, is_safe_filename
# Watchlist and auto-download
@@ -73,6 +73,17 @@ download_manager = DownloadManager(download_dir="downloads", max_parallel=3)
episode_checker.set_download_manager(download_manager)
@app.on_event("startup")
async def startup_event():
from app.sonarr_handler import get_sonarr_handler
sonarr_handler = get_sonarr_handler()
sonarr_handler.set_download_manager(download_manager)
from app.auto_download_scheduler import auto_download_scheduler
auto_download_scheduler.start()
logger.info("Application started: Sonarr handler and scheduler initialized")
def restore_completed_downloads():
"""Scan downloads directory and restore completed download tasks"""
import logging
@@ -186,15 +197,16 @@ async def get_current_user_from_token(credentials: HTTPAuthorizationCredentials
headers={"WWW-Authenticate": "Bearer"},
)
user = user_manager.get_user(username)
if user is None:
user_dict = user_manager.get_user(username)
if user_dict is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return user
# Convert dict to User Pydantic model
return User(**user_dict)
@app.post("/api/auth/register")
@@ -294,7 +306,7 @@ async def login(form_data: UserLogin):
@app.get("/api/auth/me")
async def get_me(current_user: dict = Depends(get_current_user_from_token)):
async def get_me(current_user: User = Depends(get_current_user_from_token)):
"""
Get current user information
@@ -303,13 +315,13 @@ async def get_me(current_user: dict = Depends(get_current_user_from_token)):
"""
return {
"user": {
"id": current_user["id"],
"username": current_user["username"],
"email": current_user.get("email"),
"full_name": current_user.get("full_name"),
"is_active": current_user.get("is_active", True),
"created_at": current_user.get("created_at"),
"last_login": current_user.get("last_login")
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"full_name": current_user.full_name,
"is_active": current_user.is_active,
"created_at": current_user.created_at,
"last_login": current_user.last_login
}
}
@@ -1808,7 +1820,7 @@ async def trigger_sonarr_download(request: SonarrDownloadRequest, background_tas
@app.post("/api/watchlist", response_model=WatchlistItem, tags=["Watchlist"])
async def add_to_watchlist(
item_data: WatchlistItemCreate,
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user_from_token)
):
"""Add an anime to the watchlist for automatic episode tracking"""
try:
@@ -1824,7 +1836,7 @@ async def add_to_watchlist(
@app.get("/api/watchlist", response_model=List[WatchlistItem], tags=["Watchlist"])
async def get_watchlist(
status: Optional[WatchlistStatus] = None,
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user_from_token)
):
"""Get user's watchlist, optionally filtered by status"""
try:
@@ -1835,161 +1847,9 @@ async def get_watchlist(
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/watchlist/{item_id}", response_model=WatchlistItem, tags=["Watchlist"])
async def get_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user)
):
"""Get a specific watchlist item"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
return item
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/watchlist/{item_id}", response_model=WatchlistItem, tags=["Watchlist"])
async def update_watchlist_item(
item_id: str,
update_data: WatchlistItemUpdate,
current_user: User = Depends(get_current_user)
):
"""Update a watchlist item (settings, status, etc.)"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
updated_item = watchlist_manager.update(item_id, update_data)
return updated_item
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/watchlist/{item_id}", tags=["Watchlist"])
async def delete_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user)
):
"""Delete an anime from the watchlist"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
success = watchlist_manager.delete(item_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete item")
return {"status": "success", "message": "Item deleted from watchlist"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/watchlist/{item_id}/check", tags=["Watchlist"])
async def check_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user)
):
"""Manually trigger a check for new episodes of a specific anime"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
result = await episode_checker.manual_check(item_id)
if not result:
raise HTTPException(status_code=500, detail="Check failed")
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error checking watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/watchlist/{item_id}/pause", response_model=WatchlistItem, tags=["Watchlist"])
async def pause_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user)
):
"""Pause automatic downloading for a specific anime"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
update_data = WatchlistItemUpdate(status=WatchlistStatus.PAUSED)
updated_item = watchlist_manager.update(item_id, update_data)
return updated_item
except HTTPException:
raise
except Exception as e:
logger.error(f"Error pausing watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/watchlist/{item_id}/resume", response_model=WatchlistItem, tags=["Watchlist"])
async def resume_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user)
):
"""Resume automatic downloading for a paused anime"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
update_data = WatchlistItemUpdate(status=WatchlistStatus.ACTIVE)
updated_item = watchlist_manager.update(item_id, update_data)
return updated_item
except HTTPException:
raise
except Exception as e:
logger.error(f"Error resuming watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/watchlist/settings", response_model=WatchlistSettings, tags=["Watchlist"])
async def get_watchlist_settings(
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user_from_token)
):
"""Get global watchlist settings"""
try:
@@ -2003,7 +1863,7 @@ async def get_watchlist_settings(
@app.put("/api/watchlist/settings", response_model=WatchlistSettings, tags=["Watchlist"])
async def update_watchlist_settings(
settings: WatchlistSettings,
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user_from_token)
):
"""Update global watchlist settings"""
try:
@@ -2021,7 +1881,7 @@ async def update_watchlist_settings(
@app.get("/api/watchlist/stats", tags=["Watchlist"])
async def get_watchlist_stats(
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user_from_token)
):
"""Get watchlist statistics"""
try:
@@ -2034,7 +1894,7 @@ async def get_watchlist_stats(
@app.post("/api/watchlist/check-all", tags=["Watchlist"])
async def check_all_watchlist_items(
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user_from_token)
):
"""Manually trigger a check for all due watchlist items"""
try:
@@ -2061,7 +1921,7 @@ async def check_all_watchlist_items(
@app.get("/api/watchlist/scheduler/status", tags=["Watchlist"])
async def get_scheduler_status(
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user_from_token)
):
"""Get auto-download scheduler status"""
try:
@@ -2077,7 +1937,7 @@ async def get_scheduler_status(
@app.post("/api/watchlist/scheduler/start", tags=["Watchlist"])
async def start_scheduler(
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user_from_token)
):
"""Start the auto-download scheduler"""
try:
@@ -2093,7 +1953,7 @@ async def start_scheduler(
@app.post("/api/watchlist/scheduler/stop", tags=["Watchlist"])
async def stop_scheduler(
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user_from_token)
):
"""Stop the auto-download scheduler"""
try:
@@ -2107,6 +1967,158 @@ async def stop_scheduler(
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/watchlist/{item_id}", response_model=WatchlistItem, tags=["Watchlist"])
async def get_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user_from_token)
):
"""Get a specific watchlist item"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
return item
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/watchlist/{item_id}", response_model=WatchlistItem, tags=["Watchlist"])
async def update_watchlist_item(
item_id: str,
update_data: WatchlistItemUpdate,
current_user: User = Depends(get_current_user_from_token)
):
"""Update a watchlist item (settings, status, etc.)"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
updated_item = watchlist_manager.update(item_id, update_data)
return updated_item
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/watchlist/{item_id}", tags=["Watchlist"])
async def delete_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user_from_token)
):
"""Delete an anime from the watchlist"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
success = watchlist_manager.delete(item_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete item")
return {"status": "success", "message": "Item deleted from watchlist"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/watchlist/{item_id}/check", tags=["Watchlist"])
async def check_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user_from_token)
):
"""Manually trigger a check for new episodes of a specific anime"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
result = await episode_checker.manual_check(item_id)
if not result:
raise HTTPException(status_code=500, detail="Check failed")
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error checking watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/watchlist/{item_id}/pause", response_model=WatchlistItem, tags=["Watchlist"])
async def pause_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user_from_token)
):
"""Pause automatic downloading for a specific anime"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
update_data = WatchlistItemUpdate(status=WatchlistStatus.PAUSED)
updated_item = watchlist_manager.update(item_id, update_data)
return updated_item
except HTTPException:
raise
except Exception as e:
logger.error(f"Error pausing watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/watchlist/{item_id}/resume", response_model=WatchlistItem, tags=["Watchlist"])
async def resume_watchlist_item(
item_id: str,
current_user: User = Depends(get_current_user_from_token)
):
"""Resume automatic downloading for a paused anime"""
try:
item = watchlist_manager.get_by_id(item_id)
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
# Check ownership
if item.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
update_data = WatchlistItemUpdate(status=WatchlistStatus.ACTIVE)
updated_item = watchlist_manager.update(item_id, update_data)
return updated_item
except HTTPException:
raise
except Exception as e:
logger.error(f"Error resuming watchlist item: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
uvicorn.run(
"main:app",