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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user