import os import json import uuid import secrets import zipfile import io import httpx # For making async API calls to Jikan from datetime import datetime, timedelta from typing import List, Dict, Optional, Any from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Query, Request from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware from jose import JWTError, jwt from passlib.context import CryptContext from pydantic import BaseModel, Field from fastapi.templating import Jinja2Templates # --- Configuration --- JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", secrets.token_hex(32)) ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days # --- Persistent Data Paths --- DATA_DIR = "data" USERS_DB_FILE = os.path.join(DATA_DIR, "users.json") UPLOAD_DIR = os.path.join(DATA_DIR, "uploads") STATIC_DIR = "static" os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(STATIC_DIR, exist_ok=True) # Ensure static dir exists # --- Security & Hashing --- pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # --- Pydantic Models --- class Token(BaseModel): access_token: str token_type: str class TokenData(BaseModel): username: Optional[str] = None class WatchHistoryEntry(BaseModel): show_id: str show_title: str season_number: int episode_number: int watch_timestamp: datetime class UserBase(BaseModel): username: str class UserCreate(UserBase): password: str class UserInDB(UserBase): hashed_password: str profile_picture_url: Optional[str] = None watch_history: List[Dict[str, Any]] = Field(default_factory=list) class UserPublic(UserBase): profile_picture_url: Optional[str] = None watch_history_detailed: Dict[str, Any] = Field(default_factory=dict) email: Optional[str] = None class PasswordChange(BaseModel): current_password: str new_password: str # --- Database Helper Functions --- def load_users() -> Dict[str, Dict]: if not os.path.exists(USERS_DB_FILE): return {} try: with open(USERS_DB_FILE, "r") as f: return json.load(f) except (json.JSONDecodeError, FileNotFoundError): return {} def save_users(users_db: Dict[str, Dict]): def json_serializer(obj): if isinstance(obj, datetime): return obj.isoformat() raise TypeError(f"Type {type(obj)} not serializable") with open(USERS_DB_FILE, "w") as f: json.dump(users_db, f, indent=4, default=json_serializer) # --- Password & Token Functions --- def verify_password(plain_password, hashed_password): return pwd_context.verify(plain_password, hashed_password) def get_password_hash(password): return pwd_context.hash(password) def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode = data.copy() expire_time = datetime.utcnow() + (expires_delta if expires_delta else timedelta(minutes=15)) to_encode.update({"exp": expire_time}) return jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=ALGORITHM) # --- Dependency to get current user --- async def get_current_user(token: str = Depends(oauth2_scheme)) -> UserInDB: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") if username is None: raise credentials_exception token_data = TokenData(username=username) except JWTError: raise credentials_exception user = load_users().get(token_data.username) if user is None: raise credentials_exception return UserInDB(**user) # --- FastAPI App Initialization --- app = FastAPI(title="Anime PWA API") templates = Jinja2Templates(directory=STATIC_DIR) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- Helper Functions --- def structure_watch_history(history_list: List[Dict]) -> Dict: structured = {} sorted_history = sorted(history_list, key=lambda x: x.get("watch_timestamp", ""), reverse=True) for item in sorted_history: # ... (rest of your existing function) show_id = item.get("show_id") show_title = item.get("show_title", "Unknown Show") season_num = item.get("season_number") episode_num = item.get("episode_number") timestamp = item.get("watch_timestamp") if not all([show_id, season_num is not None, episode_num is not None, timestamp]): continue if show_id not in structured: structured[show_id] = { "show_id": show_id, "title": show_title, "seasons": {} } if season_num not in structured[show_id]["seasons"]: structured[show_id]["seasons"][season_num] = { "season_number": season_num, "episodes": {} } structured[show_id]["seasons"][season_num]["episodes"][episode_num] = timestamp return structured async def get_anime_poster_url(anime_title: str) -> Optional[str]: """Fetches the top anime poster URL from Jikan API.""" try: async with httpx.AsyncClient() as client: # Using Jikan API v4 response = await client.get(f"https://api.jikan.moe/v4/anime?q={anime_title}&limit=1") response.raise_for_status() data = response.json() if data.get("data"): # Get the large JPG image URL return data["data"][0]["images"]["jpg"]["large_image_url"] except Exception as e: print(f"Error fetching poster for '{anime_title}': {e}") return None # Return None on failure return None # --- HTML Content --- DOWNLOAD_UI_HTML = """
Generating your files... Please wait.