diff --git a/app/api/endpoints/manifest.py b/app/api/endpoints/manifest.py index 30261f5..f533010 100644 --- a/app/api/endpoints/manifest.py +++ b/app/api/endpoints/manifest.py @@ -7,7 +7,6 @@ @router.get("/manifest.json") async def manifest(): - """Get base manifest for unauthenticated users.""" manifest = manifest_service.get_base_manifest() # since user is not logged in, return empty catalogs manifest["catalogs"] = [] @@ -16,5 +15,4 @@ async def manifest(): @router.get("/{token}/manifest.json") async def manifest_token(token: str): - """Get manifest for authenticated user.""" return await manifest_service.get_manifest_for_token(token) diff --git a/app/api/endpoints/tokens.py b/app/api/endpoints/tokens.py index bea80c0..bb2b541 100644 --- a/app/api/endpoints/tokens.py +++ b/app/api/endpoints/tokens.py @@ -1,229 +1,42 @@ -from datetime import datetime, timezone -from typing import Literal - -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse from loguru import logger -from pydantic import BaseModel, Field - -from app.core.config import settings -from app.core.security import redact_token -from app.core.settings import CatalogConfig, PosterRatingConfig, UserSettings, get_default_settings -from app.services.manifest import manifest_service -from app.services.stremio.service import StremioBundle -from app.services.token_store import token_store - -router = APIRouter(prefix="/tokens", tags=["tokens"]) - - -class TokenRequest(BaseModel): - authKey: str | None = Field(default=None, description="Stremio auth key") - email: str | None = Field(default=None, description="Stremio account email") - password: str | None = Field(default=None, description="Stremio account password (stored securely)") - catalogs: list[CatalogConfig] | None = Field(default=None, description="Optional catalog configuration") - language: str = Field(default="en-US", description="Language for TMDB API") - poster_rating: PosterRatingConfig | None = Field(default=None, description="Poster rating provider configuration") - excluded_movie_genres: list[str] = Field(default_factory=list, description="List of movie genre IDs to exclude") - excluded_series_genres: list[str] = Field(default_factory=list, description="List of series genre IDs to exclude") - popularity: Literal["mainstream", "balanced", "gems", "all"] = Field( - default="balanced", description="Popularity for TMDB API" - ) - year_min: int = Field(default=2010, description="Minimum release year for TMDB API") - year_max: int = Field(default=2025, description="Maximum release year for TMDB API") - sorting_order: Literal["default", "movies_first", "series_first"] = Field( - default="default", description="Order of movies and series catalogs" - ) - simkl_api_key: str | None = Field(default=None, description="Simkl API Key for the user") - gemini_api_key: str | None = Field(default=None, description="Gemini API Key for AI features") - tmdb_api_key: str | None = Field( - default=None, description="TMDB API Key (required for new clients if server has none)" - ) +from app.api.models.tokens import TokenRequest, TokenResponse +from app.services.auth import auth_service -class TokenResponse(BaseModel): - token: str - manifestUrl: str - expiresInSeconds: int | None = Field( - default=None, - description="Number of seconds before the token expires (None means it does not expire)", - ) - - -async def _verify_credentials_or_raise(bundle: StremioBundle, auth_key: str) -> str: - """Ensure the supplied auth key is valid.""" - try: - await bundle.auth.get_user_info(auth_key) - return auth_key - except Exception as exc: - raise HTTPException( - status_code=400, - detail="Invalid Stremio auth key.", - ) from exc +router = APIRouter(prefix="/tokens", tags=["Tokens"]) @router.post("/", response_model=TokenResponse) -async def create_token(payload: TokenRequest, request: Request) -> TokenResponse: - # Prefer email+password if provided; else require authKey - email = (payload.email or "").strip() or None - password = (payload.password or "").strip() or None - stremio_auth_key = (payload.authKey or "").strip() or None - - if not (email and password) and not stremio_auth_key: - raise HTTPException(status_code=400, detail="Provide email+password or a valid Stremio auth key.") - - # Remove quotes if present for authKey - if stremio_auth_key and stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'): - stremio_auth_key = stremio_auth_key[1:-1].strip() - - bundle = StremioBundle() - # 1. Establish a valid auth key and fetch user info - if email and password: - stremio_auth_key = await bundle.auth.login(email, password) - +async def create_token(payload: TokenRequest) -> TokenResponse: try: - user_info = await bundle.auth.get_user_info(stremio_auth_key) - user_id = user_info["user_id"] - resolved_email = user_info.get("email", "") - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to verify Stremio identity: {e}") - - # 2. Check if user already exists - token = token_store.get_token_from_user_id(user_id) - existing_data = await token_store.get_user_data(token) - - # 3. Construct Settings - default_settings = get_default_settings() - poster_rating = payload.poster_rating - user_settings = UserSettings( - language=payload.language or default_settings.language, - catalogs=payload.catalogs if payload.catalogs else default_settings.catalogs, - poster_rating=poster_rating, - excluded_movie_genres=payload.excluded_movie_genres, - excluded_series_genres=payload.excluded_series_genres, - year_min=payload.year_min, - year_max=payload.year_max, - popularity=payload.popularity, - sorting_order=payload.sorting_order, - simkl_api_key=payload.simkl_api_key, - gemini_api_key=payload.gemini_api_key, - tmdb_api_key=payload.tmdb_api_key, - ) - - # 4. Prepare payload to store - payload_to_store = { - "authKey": stremio_auth_key, - "email": resolved_email or email or "", - "settings": user_settings.model_dump(), - } - if existing_data: - payload_to_store["last_updated"] = existing_data.get("last_updated") - else: - payload_to_store["last_updated"] = datetime.now(timezone.utc).isoformat() - - if email and password: - payload_to_store["password"] = password - - # 5. Store user data - token = await token_store.store_user_data(user_id, payload_to_store) - account_status = "updated" if existing_data else "created" - logger.info(f"[{redact_token(token)}] Account {account_status} for user {user_id}") - - # 6. Cache library items and profiles before returning - # This ensures manifest generation is fast when user installs the addon - # We wait for caching to complete so everything is ready immediately - try: - logger.info(f"[{redact_token(token)}] Caching library and profiles before returning token") - await manifest_service.cache_library_and_profiles(bundle, stremio_auth_key, user_settings, token) - logger.info(f"[{redact_token(token)}] Successfully cached library and profiles") - except Exception as e: - logger.warning( - f"[{redact_token(token)}] Failed to cache library and profiles: {e}. " - "Continuing anyway - will cache on manifest request." - ) - # Continue even if caching fails - manifest service will handle it - - base_url = settings.HOST_NAME - manifest_url = f"{base_url}/{token}/manifest.json" - expires_in = settings.TOKEN_TTL_SECONDS if settings.TOKEN_TTL_SECONDS > 0 else None - - await bundle.close() - - return TokenResponse( - token=token, - manifestUrl=manifest_url, - expiresInSeconds=expires_in, - ) - - -async def get_stremio_user_data(payload: TokenRequest) -> tuple[str, str]: - bundle = StremioBundle() - try: - email = (payload.email or "").strip() or None - password = (payload.password or "").strip() or None - auth_key = (payload.authKey or "").strip() or None - - if email and password: - try: - auth_key = await bundle.auth.login(email, password) - user_info = await bundle.auth.get_user_info(auth_key) - return user_info["user_id"], user_info.get("email", email) - except Exception as e: - logger.error(f"Stremio identity check failed: {e}") - raise HTTPException(status_code=400, detail="Failed to verify Stremio identity.") - elif auth_key: - if auth_key.startswith('"') and auth_key.endswith('"'): - auth_key = auth_key[1:-1].strip() - try: - user_info = await bundle.auth.get_user_info(auth_key) - return user_info["user_id"], user_info.get("email", "") - except Exception as e: - logger.error(f"Stremio identity check failed: {e}") - raise HTTPException(status_code=400, detail="Invalid Stremio auth key.") - else: - raise HTTPException(status_code=400, detail="Credentials required.") - finally: - await bundle.close() + return await auth_service.create_user_token(payload) + except HTTPException: + raise + except Exception as exc: + logger.exception(f"Token creation failed: {exc}") + raise HTTPException(status_code=503, detail="Storage temporarily unavailable.") @router.post("/stremio-identity", status_code=200) async def check_stremio_identity(payload: TokenRequest): - """Fetch user info from Stremio and check if account exists.""" - user_id, email = await get_stremio_user_data(payload) try: - token = token_store.get_token_from_user_id(user_id) - user_data = await token_store.get_user_data(token) - exists = bool(user_data) - except Exception: - exists = False - user_data = None - - response = {"user_id": user_id, "email": email, "exists": exists} - if exists and user_data: - # Reconstruct UserSettings to ensure defaults (like sorting_order) are included for old accounts - raw_settings = user_data.get("settings", {}) - try: - user_settings = UserSettings(**raw_settings) - response["settings"] = user_settings.model_dump() - except Exception as e: - logger.warning(f"Failed to normalize settings for user {user_id}: {e}") - response["settings"] = raw_settings - return response + return await auth_service.get_identity_with_settings(payload) + except HTTPException: + raise + except Exception as exc: + logger.exception(f"Identity check failed: {exc}") + raise HTTPException(status_code=503, detail="Service temporarily unavailable.") @router.delete("/", status_code=200) async def delete_redis_token(payload: TokenRequest): - """Delete a token based on Stremio credentials.""" try: - user_id, _ = await get_stremio_user_data(payload) - token = token_store.get_token_from_user_id(user_id) - existing_data = await token_store.get_user_data(token) - if not existing_data: - raise HTTPException(status_code=404, detail="Account not found.") - - await token_store.delete_token(token) - logger.info(f"[{redact_token(token)}] Token deleted for user {user_id}") - return {"detail": "Settings deleted successfully"} + await auth_service.delete_user_account(payload) + return JSONResponse(status_code=200, content="Settings deleted successfully") except HTTPException: raise except Exception as exc: - logger.error(f"Token deletion failed: {exc}") - raise HTTPException(status_code=503, detail="Storage temporarily unavailable.") + logger.exception(f"Account deletion failed: {exc}") + raise HTTPException(status_code=503, detail="Service temporarily unavailable.") diff --git a/app/api/models/tokens.py b/app/api/models/tokens.py new file mode 100644 index 0000000..60b1a9c --- /dev/null +++ b/app/api/models/tokens.py @@ -0,0 +1,36 @@ +from typing import Literal + +from pydantic import BaseModel, Field + +from app.core.settings import CatalogConfig, PosterRatingConfig + + +class TokenRequest(BaseModel): + authKey: str | None = Field(default=None, description="Stremio auth key") + email: str | None = Field(default=None, description="Stremio account email") + password: str | None = Field(default=None, description="Stremio account password") + catalogs: list[CatalogConfig] | None = Field(default=None, description="Catalog configuration") + language: str = Field(default="en-US", description="Language for TMDB API") + poster_rating: PosterRatingConfig | None = Field(default=None, description="Poster rating provider configuration") + excluded_movie_genres: list[str] = Field(default_factory=list, description="List of movie genre IDs to exclude") + excluded_series_genres: list[str] = Field(default_factory=list, description="List of series genre IDs to exclude") + popularity: Literal["mainstream", "balanced", "gems", "all"] = Field( + default="balanced", description="Popularity for TMDB API" + ) + year_min: int = Field(default=2010, description="Minimum release year for TMDB API") + year_max: int = Field(default=2026, description="Maximum release year for TMDB API") + sorting_order: Literal["default", "movies_first", "series_first"] = Field( + default="default", description="Order of movies and series catalogs" + ) + simkl_api_key: str | None = Field(default=None, description="Simkl API Key for the user") + gemini_api_key: str | None = Field(default=None, description="Gemini API Key for AI features") + tmdb_api_key: str | None = Field(default=None, description="TMDB API Key") + + +class TokenResponse(BaseModel): + token: str + manifestUrl: str + expiresInSeconds: int | None = Field( + default=None, + description="Number of seconds before the token expires (None means it does not expire)", + ) diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..16dd23d --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,199 @@ +from datetime import datetime, timezone + +from fastapi import HTTPException +from loguru import logger + +from app.api.models.tokens import TokenRequest, TokenResponse +from app.core.config import settings +from app.core.security import redact_token +from app.core.settings import UserSettings, get_default_settings +from app.services.manifest import manifest_service +from app.services.stremio.service import StremioBundle +from app.services.token_store import token_store + + +class AuthService: + async def resolve_auth_key(self, credentials: dict, token: str | None = None) -> str | None: + """Validate auth key. If expired, try email+password login. Update store on refresh.""" + auth_key = (credentials.get("authKey") or "").strip() or None + email = (credentials.get("email") or "").strip() or None + password = (credentials.get("password") or "").strip() or None + + if auth_key and auth_key.startswith('"') and auth_key.endswith('"'): + auth_key = auth_key[1:-1].strip() + + bundle = StremioBundle() + try: + # 1. Try existing auth key + if auth_key: + try: + await bundle.auth.get_user_info(auth_key) + return auth_key + except Exception: + logger.info("Stremio auth key expired or invalid, attempting refresh with credentials") + + # 2. Try login if auth key failed or wasn't provided + if email and password: + try: + new_key = await bundle.auth.login(email, password) + if token and new_key != auth_key: + existing_data = await self.get_credentials(token) + if existing_data: + existing_data["authKey"] = new_key + await token_store.update_user_data(token, existing_data) + return new_key + except Exception as e: + logger.error(f"Stremio login failed: {e}") + return None + finally: + await bundle.close() + + return None + + async def get_credentials(self, token: str) -> dict | None: + """Get user credentials from token store.""" + return await token_store.get_user_data(token) + + async def store_credentials(self, user_id: str, payload: dict) -> str: + """Store credentials, return token.""" + # Ensure last_updated is present if it's a new user + if "last_updated" not in payload: + token = token_store.get_token_from_user_id(user_id) + existing = await self.get_credentials(token) + if existing: + payload["last_updated"] = existing.get("last_updated") + else: + payload["last_updated"] = datetime.now(timezone.utc).isoformat() + + return await token_store.store_user_data(user_id, payload) + + async def get_stremio_user_data(self, payload: TokenRequest) -> tuple[str, str, str]: + """ + Authenticates with Stremio and returns (user_id, email, auth_key). + """ + creds = payload.model_dump() + auth_key = await self.resolve_auth_key(creds) + + if not auth_key: + raise HTTPException(status_code=400, detail="Failed to verify Stremio identity. Provide valid credentials.") + + bundle = StremioBundle() + try: + user_info = await bundle.auth.get_user_info(auth_key) + user_id = user_info["user_id"] + resolved_email = user_info.get("email", payload.email or "") + return user_id, resolved_email, auth_key + except Exception as e: + logger.error(f"Stremio identity verification failed: {e}") + raise HTTPException(status_code=400, detail=f"Failed to verify Stremio identity: {e}") + finally: + await bundle.close() + + async def create_user_token(self, payload: TokenRequest) -> TokenResponse: + """ + Main logic for creating or updating a user token and caching their library. + """ + # 1. Authenticate and get user info + user_id, resolved_email, stremio_auth_key = await self.get_stremio_user_data(payload) + + # 2. Check if user already exists + token = token_store.get_token_from_user_id(user_id) + existing_data = await self.get_credentials(token) + + # 3. Prepare payload + user_settings = self._build_user_settings(payload) + payload_to_store = { + "authKey": stremio_auth_key, + "email": resolved_email, + "settings": user_settings.model_dump(), + } + if payload.password: + payload_to_store["password"] = payload.password.strip() + + if existing_data: + payload_to_store["last_updated"] = existing_data.get("last_updated") + + # 4. Store user data + token = await self.store_credentials(user_id, payload_to_store) + + # 5. Cache library items and profiles + await self._trigger_initial_caching(stremio_auth_key, user_settings, token) + + # 6. Build response + base_url = settings.HOST_NAME + manifest_url = f"{base_url}/{token}/manifest.json" + expires_in = settings.TOKEN_TTL_SECONDS if settings.TOKEN_TTL_SECONDS > 0 else None + + return TokenResponse( + token=token, + manifestUrl=manifest_url, + expiresInSeconds=expires_in, + ) + + def _build_user_settings(self, payload: TokenRequest) -> UserSettings: + default_settings = get_default_settings() + return UserSettings( + language=payload.language or default_settings.language, + catalogs=payload.catalogs if payload.catalogs else default_settings.catalogs, + poster_rating=payload.poster_rating, + excluded_movie_genres=payload.excluded_movie_genres, + excluded_series_genres=payload.excluded_series_genres, + year_min=payload.year_min, + year_max=payload.year_max, + popularity=payload.popularity, + sorting_order=payload.sorting_order, + simkl_api_key=payload.simkl_api_key, + gemini_api_key=payload.gemini_api_key, + tmdb_api_key=payload.tmdb_api_key, + ) + + async def get_identity_with_settings(self, payload: TokenRequest) -> dict: + """Fetch Stremio identity and associated user settings if they exist.""" + user_id, email, _ = await self.get_stremio_user_data(payload) + + token = token_store.get_token_from_user_id(user_id) + existing_data = await self.get_credentials(token) + exists = bool(existing_data) + + response = {"user_id": user_id, "email": email, "exists": exists} + + if exists and existing_data: + # Reconstruct UserSettings to ensure defaults are included for old accounts + raw_settings = existing_data.get("settings", {}) + try: + user_settings = UserSettings(**raw_settings) + response["settings"] = user_settings.model_dump() + except Exception as e: + logger.warning(f"Failed to normalize settings for user {user_id}: {e}") + response["settings"] = raw_settings + + return response + + async def delete_user_account(self, payload: TokenRequest) -> None: + """Deletes user account and associated data.""" + user_id, _, _ = await self.get_stremio_user_data(payload) + token = token_store.get_token_from_user_id(user_id) + + existing_data = await self.get_credentials(token) + if not existing_data: + raise HTTPException(status_code=404, detail="Account not found.") + + await token_store.delete_token(token) + logger.info(f"[{redact_token(token)}] Token deleted for user {user_id}") + + async def _trigger_initial_caching(self, auth_key: str, settings: UserSettings, token: str): + bundle = StremioBundle() + try: + logger.info(f"[{redact_token(token)}] Caching library and profiles before returning token") + await manifest_service.cache_library_and_profiles(bundle, auth_key, settings, token) + logger.info(f"[{redact_token(token)}] Successfully cached library and profiles") + except Exception as e: + logger.warning( + f"[{redact_token(token)}] Failed to cache library and profiles: {e}. " + "Continuing anyway - will cache on manifest request." + ) + finally: + await bundle.close() + + +auth_service = AuthService()