diff --git a/app/api/endpoints/catalogs.py b/app/api/endpoints/catalogs.py index eddc972..378a865 100644 --- a/app/api/endpoints/catalogs.py +++ b/app/api/endpoints/catalogs.py @@ -1,3 +1,5 @@ +import re + from fastapi import APIRouter, HTTPException, Response from loguru import logger @@ -38,9 +40,8 @@ async def get_catalog( raise HTTPException(status_code=400, detail="Invalid type. Use 'movie' or 'series'") # Supported IDs now include dynamic themes and item-based rows - if ( - id != "watchly.rec" - and not any(id.startswith(p) for p in ("tt", "watchly.theme.", "watchly.item.", "watchly.loved.", "watchly.watched.")) + if id != "watchly.rec" and not any( + id.startswith(p) for p in ("tt", "watchly.theme.", "watchly.item.", "watchly.loved.", "watchly.watched.") ): logger.warning(f"Invalid id: {id}") raise HTTPException( diff --git a/app/api/endpoints/tokens.py b/app/api/endpoints/tokens.py index 0834d24..cd53017 100644 --- a/app/api/endpoints/tokens.py +++ b/app/api/endpoints/tokens.py @@ -15,11 +15,7 @@ class TokenRequest(BaseModel): - watchly_username: str | None = Field(default=None, description="Watchly account (user/id)") - watchly_password: str | None = Field(default=None, description="Watchly account password") - username: str | None = Field(default=None, description="Stremio username or email") - password: str | None = Field(default=None, description="Stremio password") - authKey: str | None = Field(default=None, description="Existing Stremio auth key") + authKey: str | None = Field(default=None, description="Stremio auth key") catalogs: list[CatalogConfig] | None = Field(default=None, description="Optional catalog configuration") language: str = Field(default="en-US", description="Language for TMDB API") rpdb_key: str | None = Field(default=None, description="Optional RPDB API Key") @@ -38,18 +34,13 @@ class TokenResponse(BaseModel): async def _verify_credentials_or_raise(payload: dict) -> str: """Ensure the supplied credentials/auth key are valid before issuing tokens.""" - stremio_service = StremioService( - username=payload.get("username") or "", - password=payload.get("password") or "", - auth_key=payload.get("authKey"), - ) + stremio_service = StremioService(auth_key=payload.get("authKey")) try: - if payload.get("authKey") and not payload.get("username"): + if payload.get("authKey"): await stremio_service.get_addons(auth_key=payload["authKey"]) return payload["authKey"] - auth_key = await stremio_service.get_auth_key() - return auth_key + raise ValueError("Please Login using stremio account to continue!") except ValueError as exc: raise HTTPException( status_code=400, @@ -79,21 +70,33 @@ async def _verify_credentials_or_raise(payload: dict) -> str: @router.post("/", response_model=TokenResponse) async def create_token(payload: TokenRequest, request: Request) -> TokenResponse: - # Stremio Credentials - stremio_username = payload.username.strip() if payload.username else None - stremio_password = payload.password stremio_auth_key = payload.authKey.strip() if payload.authKey else None - # Watchly Credentials (The new flow) - watchly_username = payload.watchly_username.strip() if payload.watchly_username else None - watchly_password = payload.watchly_password + if not stremio_auth_key: + raise HTTPException(status_code=400, detail="Stremio auth key is required.") + + # Remove quotes if present + if stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'): + stremio_auth_key = stremio_auth_key[1:-1].strip() rpdb_key = payload.rpdb_key.strip() if payload.rpdb_key else None - if stremio_auth_key and stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'): - stremio_auth_key = stremio_auth_key[1:-1].strip() + # 1. Fetch user info from Stremio (user_id and email) + stremio_service = StremioService(auth_key=stremio_auth_key) + try: + user_info = await stremio_service.get_user_info() + user_id = user_info["user_id"] + email = user_info.get("email", "") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to verify Stremio identity: {e}") + finally: + await stremio_service.close() - # Construct Settings + # 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() user_settings = UserSettings( @@ -104,107 +107,44 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse excluded_series_genres=payload.excluded_series_genres, ) - # Logic to handle "Update Mode" (Watchly credentials only) - is_update_mode = (watchly_username and watchly_password) and not ( - stremio_username or stremio_password or stremio_auth_key - ) - - if is_update_mode: - # User is trying to update settings using only Watchly credentials - # We must retrieve their existing Stremio credentials from the store - temp_payload_for_derivation = { - "watchly_username": watchly_username, - "watchly_password": watchly_password, - "username": None, - "password": None, - "authKey": None, - } - derived_token = token_store.derive_token(temp_payload_for_derivation) - existing_data = await token_store.get_payload(derived_token) - - if not existing_data: - raise HTTPException( - status_code=404, - detail="Account not found. Please start as a New User to connect Stremio.", - ) - - # Hydrate Stremio credentials from existing data - stremio_username = existing_data.get("username") - stremio_password = existing_data.get("password") - stremio_auth_key = existing_data.get("authKey") - - # Regular Validation Logic - if stremio_username and not stremio_password: - raise HTTPException(status_code=400, detail="Stremio password is required when username is provided.") - - if stremio_password and not stremio_username: - raise HTTPException( - status_code=400, - detail="Stremio username/email is required when password is provided.", - ) - - if not stremio_auth_key and not (stremio_username and stremio_password): - raise HTTPException( - status_code=400, - detail="Provide either a Stremio auth key or both Stremio username and password.", - ) - - # if creating a new account, check if the Watchly ID is already taken. - if watchly_username and not is_update_mode: - derived_token = token_store.derive_token( - {"watchly_username": watchly_username, "watchly_password": watchly_password} - ) - if await token_store.get_payload(derived_token): - raise HTTPException( - status_code=409, - detail="This Watchly ID is already in use. Please choose a different one or log in as an Existing User.", # noqa: E501 - ) - - # Payload to store includes BOTH Watchly and Stremio credentials + User Settings + # 4. Prepare payload to store payload_to_store = { - "watchly_username": watchly_username, - "watchly_password": watchly_password, - "username": stremio_username, - "password": stremio_password, "authKey": stremio_auth_key, + "email": email, "settings": user_settings.model_dump(), } - verified_auth_key = await _verify_credentials_or_raise(payload_to_store) + is_new_account = not existing_data + # 5. Verify Stremio connection + verified_auth_key = await _verify_credentials_or_raise({"authKey": stremio_auth_key}) + + # 6. Store user data try: - token, created = await token_store.store_payload(payload_to_store) - logger.info(f"[{redact_token(token)}] Token {'created' if created else 'updated'}") + token = await token_store.store_user_data(user_id, payload_to_store) + logger.info(f"[{redact_token(token)}] Account {'created' if is_new_account else 'updated'} for user {user_id}") except RuntimeError as exc: - logger.error("Token storage failed: {}", exc) - raise HTTPException( - status_code=500, - detail="Server configuration error: TOKEN_SALT must be set to a secure value.", - ) from exc + raise HTTPException(status_code=500, detail="Server configuration error.") from exc except (redis_exceptions.RedisError, OSError) as exc: - logger.error("Token storage unavailable: {}", exc) - raise HTTPException( - status_code=503, - detail="Token storage is temporarily unavailable. Please try again once Redis is reachable.", - ) from exc + raise HTTPException(status_code=503, detail="Storage temporarily unavailable.") from exc - if created: - try: - await refresh_catalogs_for_credentials( - payload_to_store, user_settings=user_settings, auth_key=verified_auth_key - ) - except Exception as exc: # pragma: no cover - remote dependency - logger.error(f"[{redact_token(token)}] Initial catalog refresh failed: {{}}", exc, exc_info=True) - await token_store.delete_token(token=token) + # 7. Refresh Catalogs + try: + await refresh_catalogs_for_credentials( + payload_to_store, user_settings=user_settings, auth_key=verified_auth_key + ) + except Exception as exc: + logger.error(f"Catalog refresh failed: {exc}") + if is_new_account: + # Rollback on new account creation failure + await token_store.delete_token(token) raise HTTPException( status_code=502, - detail="Credentials verified, but Watchly couldn't refresh your catalogs yet. Please try again.", + detail="Credentials verified, but catalog refresh failed. Please try again.", ) from exc base_url = settings.HOST_NAME - # New URL structure (Settings stored in Token) manifest_url = f"{base_url}/{token}/manifest.json" - expires_in = settings.TOKEN_TTL_SECONDS if settings.TOKEN_TTL_SECONDS > 0 else None return TokenResponse( @@ -214,78 +154,85 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse ) -@router.post("/verify", status_code=200) -async def verify_user(payload: TokenRequest): - """Verify if a Watchly user exists.""" - watchly_username = payload.watchly_username.strip() if payload.watchly_username else None - watchly_password = payload.watchly_password +@router.post("/stremio-identity", status_code=200) +async def check_stremio_identity(payload: TokenRequest): + """Fetch user info from Stremio and check if account exists.""" + auth_key = payload.authKey.strip() if payload.authKey else None - if not watchly_username or not watchly_password: - raise HTTPException(status_code=400, detail="Watchly username and password required.") + if not auth_key: + raise HTTPException(status_code=400, detail="Auth Key required.") - payload_to_derive = { - "watchly_username": watchly_username, - "watchly_password": watchly_password, - "username": None, - "password": None, - "authKey": None, - } + if auth_key.startswith('"') and auth_key.endswith('"'): + auth_key = auth_key[1:-1].strip() - token = token_store.derive_token(payload_to_derive) - exists = await token_store.get_payload(token) + stremio_service = StremioService(auth_key=auth_key) + try: + user_info = await stremio_service.get_user_info() + user_id = user_info["user_id"] + email = user_info.get("email", "") + except Exception as e: + logger.error(f"Stremio identity check failed: {e}") + raise HTTPException( + status_code=400, detail="Failed to verify Stremio identity. Your auth key might be invalid or expired." + ) + finally: + await stremio_service.close() + + # Check existence + 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 ValueError: + exists = False + user_data = None - if not exists: - raise HTTPException(status_code=404, detail="Account not found.") + response = {"user_id": user_id, "email": email, "exists": exists} + if exists and user_data: + response["settings"] = user_data.get("settings") - return {"found": True, "token": token, "settings": exists.get("settings")} + return response @router.delete("/", status_code=200) async def delete_token(payload: TokenRequest): - """Delete a token based on provided credentials.""" - # Stremio Credentials - stremio_username = payload.username.strip() if payload.username else None - stremio_password = payload.password + """Delete a token based on Stremio auth key.""" stremio_auth_key = payload.authKey.strip() if payload.authKey else None - # Watchly Credentials - watchly_username = payload.watchly_username.strip() if payload.watchly_username else None - watchly_password = payload.watchly_password - - if stremio_auth_key and stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'): - stremio_auth_key = stremio_auth_key[1:-1].strip() - - # Need either Watchly creds OR Stremio creds (for legacy) - if ( - not (watchly_username and watchly_password) - and not stremio_auth_key - and not (stremio_username and stremio_password) - ): + if not stremio_auth_key: raise HTTPException( status_code=400, - detail="Provide Watchly credentials (or Stremio credentials for legacy accounts) to delete account.", + detail="Stremio auth key is required to delete account.", ) - payload_to_derive = { - "watchly_username": watchly_username, - "watchly_password": watchly_password, - "username": stremio_username, - "password": stremio_password, - "authKey": stremio_auth_key, - } + if stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'): + stremio_auth_key = stremio_auth_key[1:-1].strip() try: - # We don't verify credentials with Stremio here, we just check if we have a token for them. - # If the user provides wrong credentials, we'll derive a wrong token, which won't exist in Redis. - # That's fine, we can just say "deleted" or "not found". - # However, to be nice, we might want to say "Settings deleted" even if they didn't exist. - # But if we want to be strict, we could check existence. - # Let's just try to delete. - - token = token_store.derive_token(payload_to_derive) + # Fetch user info from Stremio + stremio_service = StremioService(auth_key=stremio_auth_key) + try: + user_info = await stremio_service.get_user_info() + user_id = user_info["user_id"] + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to verify Stremio identity: {e}") + finally: + await stremio_service.close() + + # Get token from user_id + token = token_store.get_token_from_user_id(user_id) + + # Verify account exists + existing_data = await token_store.get_user_data(token) + if not existing_data: + raise HTTPException(status_code=404, detail="Account not found.") + + # Delete the token await token_store.delete_token(token) - logger.info(f"[{redact_token(token)}] Token deleted (if existed)") + logger.info(f"[{redact_token(token)}] Token deleted for user {user_id}") return {"detail": "Settings deleted successfully"} + except HTTPException: + raise except (redis_exceptions.RedisError, OSError) as exc: logger.error("Token deletion failed: {}", exc) raise HTTPException( diff --git a/app/core/constants.py b/app/core/constants.py new file mode 100644 index 0000000..9714637 --- /dev/null +++ b/app/core/constants.py @@ -0,0 +1 @@ +RECOMMENDATIONS_CATALOG_NAME: str = "Top Picks For You" diff --git a/app/services/stremio_service.py b/app/services/stremio_service.py index 744e108..82184fb 100644 --- a/app/services/stremio_service.py +++ b/app/services/stremio_service.py @@ -94,13 +94,10 @@ async def _login_for_auth_key(self) -> str: raise async def get_auth_key(self) -> str: - """Return a cached auth key or login to retrieve one.""" - if self._auth_key: - return self._auth_key - auth_key = await self._login_for_auth_key() - if not auth_key: - raise ValueError("Failed to obtain Stremio auth key") - return auth_key + """Return the cached auth key.""" + if not self._auth_key: + raise ValueError("Stremio auth key is missing.") + return self._auth_key async def is_loved(self, auth_key: str, imdb_id: str, media_type: str) -> tuple[bool, bool]: """ @@ -152,13 +149,54 @@ async def get_liked_items(self, auth_token: str, media_type: str) -> list[dict]: metas = response.json().get("metas", []) return [meta.get("id") for meta in metas] + async def get_user_info(self) -> dict[str, str]: + """Fetch user ID and email using the auth key.""" + if not self._auth_key: + raise ValueError("Stremio auth key is missing.") + + url = f"{self.base_url}/api/getUser" + payload = { + "type": "GetUser", + "authKey": self._auth_key, + } + + try: + client = await self._get_client() + result = await client.post(url, json=payload) + result.raise_for_status() + data = result.json() + + if "error" in data: + error_msg = data["error"] + if isinstance(error_msg, dict): + error_msg = error_msg.get("message", "Unknown error") + raise ValueError(f"Stremio Error: {error_msg}") + + # Structure: { result: { _id, email, ... } } + res = data.get("result", {}) + user_id = res.get("_id", "") + email = res.get("email", "") + + if not user_id: + raise ValueError("Could not retrieve user ID from Stremio profile.") + + return {"user_id": user_id, "email": email} + except Exception as e: + logger.error(f"Error fetching user profile: {e}") + raise + + async def get_user_email(self) -> str: + """Fetch user email using the auth key.""" + user_info = await self.get_user_info() + return user_info.get("email", "") + async def get_library_items(self) -> dict[str, list[dict]]: """ Fetch library items from Stremio once and return both watched and loved items. Returns a dict with 'watched' and 'loved' keys. """ - if not self._auth_key and (not self.username or not self.password): - logger.warning("Stremio credentials not configured") + if not self._auth_key: + logger.warning("Stremio auth key not configured") return {"watched": [], "loved": []} try: diff --git a/app/services/token_store.py b/app/services/token_store.py index 96d3098..9cafadd 100644 --- a/app/services/token_store.py +++ b/app/services/token_store.py @@ -1,6 +1,5 @@ import base64 import hashlib -import hmac import json from collections.abc import AsyncIterator from typing import Any @@ -54,113 +53,104 @@ async def _get_client(self) -> redis.Redis: self._client = redis.from_url(settings.REDIS_URL, decode_responses=True, encoding="utf-8") return self._client - def _hash_token(self, token: str) -> str: - secret = settings.TOKEN_SALT.encode("utf-8") - return hmac.new(secret, msg=token.encode("utf-8"), digestmod=hashlib.sha256).hexdigest() - - def _format_key(self, hashed_token: str) -> str: - return f"{self.KEY_PREFIX}{hashed_token}" - - def _normalize_payload(self, payload: dict[str, Any]) -> dict[str, Any]: - return { - "watchly_username": (payload.get("watchly_username") or "").strip() or None, - "watchly_password": payload.get("watchly_password") or None, - "username": (payload.get("username") or "").strip() or None, - "password": payload.get("password") or None, - "authKey": (payload.get("authKey") or "").strip() or None, - "includeWatched": bool(payload.get("includeWatched", False)), - "settings": payload.get("settings"), - } - - def _derive_token_value(self, payload: dict[str, Any]) -> str: - # Prioritize Watchly credentials for stable token generation - if payload.get("watchly_username"): - canonical = { - "watchly_username": payload.get("watchly_username"), - "watchly_password": payload.get("watchly_password") or "", - } - else: - # Legacy fallback - canonical = { - "username": payload.get("username") or "", - "password": payload.get("password") or "", - "authKey": payload.get("authKey") or "", - "includeWatched": bool(payload.get("includeWatched", False)), - } - - serialized = json.dumps(canonical, sort_keys=True, separators=(",", ":")) - secret = settings.TOKEN_SALT.encode("utf-8") - return hmac.new(secret, serialized.encode("utf-8"), hashlib.sha256).hexdigest() + def _format_key(self, token: str) -> str: + """Format Redis key from token.""" + return f"{self.KEY_PREFIX}{token}" - def derive_token(self, payload: dict[str, Any]) -> str: - """Public wrapper to derive token from payload.""" - normalized = self._normalize_payload(payload) - return self._derive_token_value(normalized) + def _encrypt_password(self, password: str) -> str: + """Encrypt password using Fernet.""" + if not password: + return None + return self._get_cipher().encrypt(password.encode()).decode("utf-8") - async def store_payload(self, payload: dict[str, Any]) -> tuple[str, bool]: + def _decrypt_password(self, encrypted_password: str) -> str: + """Decrypt password using Fernet.""" + if not encrypted_password: + return None + try: + return self._get_cipher().decrypt(encrypted_password.encode()).decode("utf-8") + except InvalidToken: + return None + + def get_token_from_user_id(self, user_id: str) -> str: + """Generate token from user_id (plain user_id as token).""" + if not user_id: + raise ValueError("User ID is required to generate token") + # Use user_id directly as token (no encryption) + return user_id.strip() + + def get_user_id_from_token(self, token: str) -> str: + """Get user_id from token (they are the same now).""" + return token.strip() if token else "" + + async def store_user_data(self, user_id: str, payload: dict[str, Any]) -> str: self._ensure_secure_salt() - normalized = self._normalize_payload(payload) - token = self._derive_token_value(normalized) - hashed = self._hash_token(token) - key = self._format_key(hashed) - # JSON Encode -> Encrypt -> Store - json_str = json.dumps(normalized) - encrypted_value = self._get_cipher().encrypt(json_str.encode()).decode("utf-8") + token = self.get_token_from_user_id(user_id) + key = self._format_key(token) + + # Prepare data for storage (Plain JSON, no password encryption needed) + storage_data = payload.copy() + + # Store user_id in payload for convenience + storage_data["user_id"] = user_id client = await self._get_client() - existing = await client.exists(key) + json_str = json.dumps(storage_data) if settings.TOKEN_TTL_SECONDS and settings.TOKEN_TTL_SECONDS > 0: - await client.setex(key, settings.TOKEN_TTL_SECONDS, encrypted_value) - logger.info( - f"Stored encrypted credential payload with TTL {settings.TOKEN_TTL_SECONDS} seconds", - ) + await client.setex(key, settings.TOKEN_TTL_SECONDS, json_str) else: - await client.set(key, encrypted_value) - logger.info("Stored encrypted credential payload without expiration") + await client.set(key, json_str) - # Cache the new payload immediately to avoid next-read hit - self._payload_cache[token] = normalized + # Update cache with the payload + self._payload_cache[token] = payload - return token, not bool(existing) + return token - async def get_payload(self, token: str) -> dict[str, Any] | None: - # Check local LRU cache first + async def get_user_data(self, token: str) -> dict[str, Any] | None: if token in self._payload_cache: return self._payload_cache[token] - hashed = self._hash_token(token) - key = self._format_key(hashed) + key = self._format_key(token) client = await self._get_client() - encrypted_raw = await client.get(key) + data_raw = await client.get(key) - if encrypted_raw is None: + if not data_raw: return None try: - # Decrypt -> JSON Decode - decrypted_json = self._get_cipher().decrypt(encrypted_raw.encode()).decode("utf-8") - payload = json.loads(decrypted_json) - - # Cache for subsequent reads - self._payload_cache[token] = payload - return payload - except (InvalidToken, json.JSONDecodeError, UnicodeDecodeError): - logger.warning("Failed to decrypt or decode cached payload for token. Key might have changed.") + data = json.loads(data_raw) + self._payload_cache[token] = data + return data + except json.JSONDecodeError: return None + # Alias for compatibility with existing calls, but implementation changed + def derive_token(self, payload: dict[str, Any]) -> str: + # We can't really derive token from mixed payload anymore unless we have email. + # This might break existing calls in `tokens.py`. We need to fix `tokens.py` to use `get_token_from_email`. + raise NotImplementedError("Use get_token_from_email instead") + + async def get_payload(self, token: str) -> dict[str, Any] | None: + return await self.get_user_data(token) + + async def store_payload(self, payload: dict[str, Any]) -> tuple[str, bool]: + # This signature doesn't match new logic which needs email explicitly or inside payload. + # We will update tokens.py first. + raise NotImplementedError("Use store_user_data instead") + async def delete_token(self, token: str = None, key: str = None) -> None: if not token and not key: raise ValueError("Either token or key must be provided") if token: - hashed = self._hash_token(token) - key = self._format_key(hashed) + key = self._format_key(token) + client = await self._get_client() await client.delete(key) # Invalidate local cache - if token in self._payload_cache: + if token and token in self._payload_cache: del self._payload_cache[token] async def iter_payloads(self) -> AsyncIterator[tuple[str, dict[str, Any]]]: @@ -172,24 +162,22 @@ async def iter_payloads(self) -> AsyncIterator[tuple[str, dict[str, Any]]]: return pattern = f"{self.KEY_PREFIX}*" - cipher = self._get_cipher() try: async for key in client.scan_iter(match=pattern): try: - encrypted_raw = await client.get(key) + data_raw = await client.get(key) except (redis.RedisError, OSError) as exc: logger.warning(f"Failed to fetch payload for {key}: {exc}") continue - if encrypted_raw is None: + if not data_raw: continue try: - decrypted_json = cipher.decrypt(encrypted_raw.encode()).decode("utf-8") - payload = json.loads(decrypted_json) - except (InvalidToken, json.JSONDecodeError, UnicodeDecodeError): - logger.warning(f"Failed to decrypt payload for key {key}. Skipping.") + payload = json.loads(data_raw) + except json.JSONDecodeError: + logger.warning(f"Failed to decode payload for key {key}. Skipping.") continue yield key, payload diff --git a/app/static/index.html b/app/static/index.html index 7fd197b..91a58f2 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -91,13 +91,28 @@