diff --git a/app/favorites.py b/app/favorites.py new file mode 100644 index 0000000..b8ffc6f --- /dev/null +++ b/app/favorites.py @@ -0,0 +1,195 @@ +""" +Favorites management system for Ohm Stream Downloader +Stores user's favorite anime with metadata in a local JSON file +""" +import json +import asyncio +from pathlib import Path +from typing import List, Dict, Optional +from datetime import datetime +import aiofiles + + +class FavoritesManager: + """Manages user's favorite anime list""" + + def __init__(self, storage_path: str = "data/favorites.json"): + self.storage_path = Path(storage_path) + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + self._favorites: Dict[str, Dict] = {} + self._lock = asyncio.Lock() + + async def _load(self): + """Load favorites from disk""" + async with self._lock: + if self.storage_path.exists(): + try: + async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as f: + content = await f.read() + self._favorites = json.loads(content) if content.strip() else {} + except Exception as e: + print(f"Error loading favorites: {e}") + self._favorites = {} + else: + self._favorites = {} + + async def _save(self): + """Save favorites to disk""" + async with self._lock: + try: + async with aiofiles.open(self.storage_path, 'w', encoding='utf-8') as f: + await f.write(json.dumps(self._favorites, indent=2, ensure_ascii=False)) + except Exception as e: + print(f"Error saving favorites: {e}") + + async def add_favorite( + self, + anime_id: str, + title: str, + url: str, + provider: str, + metadata: Optional[Dict] = None, + poster_url: Optional[str] = None + ) -> Dict: + """Add an anime to favorites""" + await self._load() + + if anime_id in self._favorites: + # Update existing favorite + self._favorites[anime_id]["updated_at"] = datetime.now().isoformat() + if metadata: + self._favorites[anime_id]["metadata"] = metadata + if poster_url: + self._favorites[anime_id]["poster_url"] = poster_url + else: + # Add new favorite + self._favorites[anime_id] = { + "id": anime_id, + "title": title, + "url": url, + "provider": provider, + "metadata": metadata or {}, + "poster_url": poster_url, + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat() + } + + await self._save() + return self._favorites[anime_id] + + async def remove_favorite(self, anime_id: str) -> bool: + """Remove an anime from favorites""" + await self._load() + + if anime_id in self._favorites: + del self._favorites[anime_id] + await self._save() + return True + + return False + + async def get_favorite(self, anime_id: str) -> Optional[Dict]: + """Get a specific favorite by ID""" + await self._load() + return self._favorites.get(anime_id) + + async def list_favorites( + self, + sort_by: str = "created_at", + order: str = "desc", + filter_provider: Optional[str] = None, + filter_genre: Optional[str] = None + ) -> List[Dict]: + """List all favorites with optional sorting and filtering""" + await self._load() + + favorites = list(self._favorites.values()) + + # Apply filters + if filter_provider: + favorites = [f for f in favorites if f["provider"] == filter_provider] + + if filter_genre: + favorites = [ + f for f in favorites + if filter_genre in f.get("metadata", {}).get("genres", []) + ] + + # Sort favorites + reverse = order == "desc" + if sort_by == "title": + favorites.sort(key=lambda x: x["title"].lower(), reverse=reverse) + elif sort_by == "rating": + favorites.sort( + key=lambda x: float(x.get("metadata", {}).get("rating", "0").split("/")[0]), + reverse=reverse + ) + elif sort_by == "year": + favorites.sort( + key=lambda x: x.get("metadata", {}).get("release_year", 0), + reverse=reverse + ) + else: # created_at, updated_at + favorites.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse) + + return favorites + + async def is_favorite(self, anime_id: str) -> bool: + """Check if an anime is in favorites""" + await self._load() + return anime_id in self._favorites + + async def toggle_favorite( + self, + anime_id: str, + title: str, + url: str, + provider: str, + metadata: Optional[Dict] = None, + poster_url: Optional[str] = None + ) -> Dict: + """Toggle an anime in favorites (add if not exists, remove if exists)""" + is_fav = await self.is_favorite(anime_id) + + if is_fav: + await self.remove_favorite(anime_id) + return {"action": "removed", "anime_id": anime_id} + else: + fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url) + return {"action": "added", "anime_id": anime_id, "favorite": fav} + + async def get_stats(self) -> Dict: + """Get statistics about favorites""" + await self._load() + + total = len(self._favorites) + + # Count by provider + by_provider = {} + for fav in self._favorites.values(): + provider = fav["provider"] + by_provider[provider] = by_provider.get(provider, 0) + 1 + + # Count by genre + by_genre = {} + for fav in self._favorites.values(): + for genre in fav.get("metadata", {}).get("genres", []): + by_genre[genre] = by_genre.get(genre, 0) + 1 + + return { + "total": total, + "by_provider": by_provider, + "by_genre": by_genre + } + + +# Global favorites manager instance +_favorites_manager: Optional[FavoritesManager] = None + + +def get_favorites_manager() -> FavoritesManager: + """Get the global favorites manager instance""" + global _favorites_manager + if _favorites_manager is None: + _favorites_manager = FavoritesManager() + return _favorites_manager diff --git a/main.py b/main.py index 291ba55..8a0775e 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ from app.models import DownloadRequest, DownloadTask, DownloadStatus from app.download_manager import DownloadManager from app.downloaders import AnimeSamaDownloader from app import providers +from app.favorites import get_favorites_manager app = FastAPI(title="Ohm Stream Downloader") @@ -42,7 +43,7 @@ async def root(): return { "message": "Ohm Stream Downloader API", "status": "running", - "version": "2.1", + "version": "2.2", "endpoints": { "POST /api/download": "Start a new download", "GET /api/downloads": "List all downloads", @@ -55,6 +56,12 @@ async def root(): "GET /api/anime/metadata": "Get detailed anime metadata (synopsis, genres, rating, etc.)", "GET /api/anime/episodes": "Get episode list for an anime", "POST /api/anime/download-season": "Download all episodes of a season", + "GET /api/favorites": "List all favorite anime", + "POST /api/favorites": "Add anime to favorites", + "DELETE /api/favorites/{anime_id}": "Remove from favorites", + "GET /api/favorites/{anime_id}": "Get favorite anime details", + "GET /api/favorites/stats": "Get favorites statistics", + "POST /api/favorites/toggle": "Toggle anime in favorites", "GET /web": "Web interface" } } @@ -547,6 +554,143 @@ async def video_player_by_filename(request: Request, filename: str): }) +# ==================== FAVORITES API ==================== + +@app.get("/api/favorites") +async def list_favorites( + sort_by: str = "created_at", + order: str = "desc", + filter_provider: str = None, + filter_genre: str = None +): + """ + List all favorite anime with optional sorting and filtering + + Query params: + - sort_by: title, rating, year, created_at, updated_at (default: created_at) + - order: asc, desc (default: desc) + - filter_provider: Filter by provider (anime-sama, neko-sama, etc.) + - filter_genre: Filter by genre (Action, Adventure, etc.) + """ + fav_manager = get_favorites_manager() + favorites = await fav_manager.list_favorites( + sort_by=sort_by, + order=order, + filter_provider=filter_provider, + filter_genre=filter_genre + ) + return { + "favorites": favorites, + "total": len(favorites), + "filters": { + "sort_by": sort_by, + "order": order, + "provider": filter_provider, + "genre": filter_genre + } + } + + +@app.post("/api/favorites") +async def add_favorite(request: Request): + """ + Add an anime to favorites + + Body params (JSON): + - anime_id: Unique identifier (e.g., provider + slug) + - title: Anime title + - url: Anime page URL + - provider: Provider name + - metadata: Optional metadata dict (synopsis, genres, rating, etc.) + - poster_url: Optional poster image URL + """ + import json + data = await request.json() + + required_fields = ["anime_id", "title", "url", "provider"] + for field in required_fields: + if field not in data: + raise HTTPException(status_code=400, detail=f"Missing required field: {field}") + + fav_manager = get_favorites_manager() + favorite = await fav_manager.add_favorite( + anime_id=data["anime_id"], + title=data["title"], + url=data["url"], + provider=data["provider"], + metadata=data.get("metadata"), + poster_url=data.get("poster_url") + ) + + return {"status": "added", "favorite": favorite} + + +@app.delete("/api/favorites/{anime_id}") +async def remove_favorite(anime_id: str): + """Remove an anime from favorites""" + fav_manager = get_favorites_manager() + removed = await fav_manager.remove_favorite(anime_id) + + if not removed: + raise HTTPException(status_code=404, detail="Favorite not found") + + return {"status": "removed", "anime_id": anime_id} + + +@app.get("/api/favorites/{anime_id}") +async def get_favorite(anime_id: str): + """Get details of a specific favorite anime""" + fav_manager = get_favorites_manager() + favorite = await fav_manager.get_favorite(anime_id) + + if not favorite: + raise HTTPException(status_code=404, detail="Favorite not found") + + return {"favorite": favorite} + + +@app.get("/api/favorites/stats") +async def get_favorites_stats(): + """Get statistics about favorites""" + fav_manager = get_favorites_manager() + stats = await fav_manager.get_stats() + return stats + + +@app.post("/api/favorites/toggle") +async def toggle_favorite(request: Request): + """ + Toggle an anime in favorites (add if not exists, remove if exists) + + Body params (JSON): + - anime_id: Unique identifier + - title: Anime title + - url: Anime page URL + - provider: Provider name + - metadata: Optional metadata dict + - poster_url: Optional poster image URL + """ + import json + data = await request.json() + + required_fields = ["anime_id", "title", "url", "provider"] + for field in required_fields: + if field not in data: + raise HTTPException(status_code=400, detail=f"Missing required field: {field}") + + fav_manager = get_favorites_manager() + result = await fav_manager.toggle_favorite( + anime_id=data["anime_id"], + title=data["title"], + url=data["url"], + provider=data["provider"], + metadata=data.get("metadata"), + poster_url=data.get("poster_url") + ) + + return result + + if __name__ == "__main__": uvicorn.run( "main:app",