diff --git a/app/api/endpoints/manifest.py b/app/api/endpoints/manifest.py index 182209a..9ce6e96 100644 --- a/app/api/endpoints/manifest.py +++ b/app/api/endpoints/manifest.py @@ -54,15 +54,22 @@ def get_base_manifest(user_settings: UserSettings | None = None): } +# Cache catalog definitions for 1 hour (3600s) # Cache catalog definitions for 1 hour (3600s) @alru_cache(maxsize=1000, ttl=3600) async def fetch_catalogs(token: str | None = None, settings_str: str | None = None): if not token: return [] - user_settings = decode_settings(settings_str) if settings_str else None - credentials = await resolve_user_credentials(token) + + if settings_str: + user_settings = decode_settings(settings_str) + elif credentials.get("settings"): + user_settings = UserSettings(**credentials["settings"]) + else: + user_settings = None + stremio_service = StremioService( username=credentials.get("username") or "", password=credentials.get("password") or "", @@ -85,7 +92,18 @@ async def _manifest_handler(response: Response, token: str | None, settings_str: # Cache manifest for 1 day (86400 seconds) response.headers["Cache-Control"] = "public, max-age=86400" - user_settings = decode_settings(settings_str) if settings_str else None + user_settings = None + if settings_str: + user_settings = decode_settings(settings_str) + elif token: + try: + creds = await resolve_user_credentials(token) + if creds.get("settings"): + user_settings = UserSettings(**creds["settings"]) + except Exception: + # Fallback to defaults if token resolution fails (or let it fail later in fetch_catalogs) + pass + base_manifest = get_base_manifest(user_settings) if token: diff --git a/app/api/endpoints/tokens.py b/app/api/endpoints/tokens.py index 299b926..0834d24 100644 --- a/app/api/endpoints/tokens.py +++ b/app/api/endpoints/tokens.py @@ -5,7 +5,7 @@ from redis import exceptions as redis_exceptions from app.core.config import settings -from app.core.settings import CatalogConfig, UserSettings, encode_settings, get_default_settings +from app.core.settings import CatalogConfig, UserSettings, get_default_settings from app.services.catalog_updater import refresh_catalogs_for_credentials from app.services.stremio_service import StremioService from app.services.token_store import token_store @@ -15,6 +15,8 @@ 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") @@ -77,34 +79,95 @@ async def _verify_credentials_or_raise(payload: dict) -> str: @router.post("/", response_model=TokenResponse) async def create_token(payload: TokenRequest, request: Request) -> TokenResponse: - username = payload.username.strip() if payload.username else None - password = payload.password - auth_key = payload.authKey.strip() if payload.authKey else None + # 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 + rpdb_key = payload.rpdb_key.strip() if payload.rpdb_key else None - if auth_key and auth_key.startswith('"') and auth_key.endswith('"'): - auth_key = auth_key[1:-1].strip() + if stremio_auth_key and stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'): + stremio_auth_key = stremio_auth_key[1:-1].strip() + + # Construct Settings + default_settings = get_default_settings() - if username and not password: - raise HTTPException(status_code=400, detail="Password is required when a username is provided.") + user_settings = UserSettings( + language=payload.language or default_settings.language, + catalogs=payload.catalogs if payload.catalogs else default_settings.catalogs, + rpdb_key=rpdb_key, + excluded_movie_genres=payload.excluded_movie_genres, + excluded_series_genres=payload.excluded_series_genres, + ) - if password and not username: + # 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="Username/email is required when a password is provided.", + detail="Stremio username/email is required when password is provided.", ) - if not auth_key and not (username and password): + 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 username and password.", + 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 + ) - # We only store credentials in Redis, settings go into URL + # Payload to store includes BOTH Watchly and Stremio credentials + User Settings payload_to_store = { - "username": username, - "password": password, - "authKey": auth_key, + "watchly_username": watchly_username, + "watchly_password": watchly_password, + "username": stremio_username, + "password": stremio_password, + "authKey": stremio_auth_key, + "settings": user_settings.model_dump(), } verified_auth_key = await _verify_credentials_or_raise(payload_to_store) @@ -125,20 +188,6 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse detail="Token storage is temporarily unavailable. Please try again once Redis is reachable.", ) from exc - # Construct Settings - default_settings = get_default_settings() - - user_settings = UserSettings( - language=payload.language or default_settings.language, - catalogs=payload.catalogs if payload.catalogs else default_settings.catalogs, - rpdb_key=rpdb_key, - excluded_movie_genres=payload.excluded_movie_genres, - excluded_series_genres=payload.excluded_series_genres, - ) - - # encode_settings now includes the "settings:" prefix - encoded_settings = encode_settings(user_settings) - if created: try: await refresh_catalogs_for_credentials( @@ -153,8 +202,8 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse ) from exc base_url = settings.HOST_NAME - # New URL structure - manifest_url = f"{base_url}/{encoded_settings}/{token}/manifest.json" + # 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 @@ -165,26 +214,64 @@ 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 + + if not watchly_username or not watchly_password: + raise HTTPException(status_code=400, detail="Watchly username and password required.") + + payload_to_derive = { + "watchly_username": watchly_username, + "watchly_password": watchly_password, + "username": None, + "password": None, + "authKey": None, + } + + token = token_store.derive_token(payload_to_derive) + exists = await token_store.get_payload(token) + + if not exists: + raise HTTPException(status_code=404, detail="Account not found.") + + return {"found": True, "token": token, "settings": exists.get("settings")} + + @router.delete("/", status_code=200) async def delete_token(payload: TokenRequest): """Delete a token based on provided credentials.""" - username = payload.username.strip() if payload.username else None - password = payload.password - auth_key = payload.authKey.strip() if payload.authKey else None - - if auth_key and auth_key.startswith('"') and auth_key.endswith('"'): - auth_key = auth_key[1:-1].strip() - - if not auth_key and not (username and password): + # 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 + 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) + ): raise HTTPException( status_code=400, - detail="Provide either a Stremio auth key or both username and password to delete account.", + detail="Provide Watchly credentials (or Stremio credentials for legacy accounts) to delete account.", ) payload_to_derive = { - "username": username, - "password": password, - "authKey": auth_key, + "watchly_username": watchly_username, + "watchly_password": watchly_password, + "username": stremio_username, + "password": stremio_password, + "authKey": stremio_auth_key, } try: diff --git a/app/services/token_store.py b/app/services/token_store.py index cf0e8e9..96d3098 100644 --- a/app/services/token_store.py +++ b/app/services/token_store.py @@ -63,19 +63,31 @@ def _format_key(self, hashed_token: str) -> str: 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: - canonical = { - "username": payload.get("username") or "", - "password": payload.get("password") or "", - "authKey": payload.get("authKey") or "", - "includeWatched": bool(payload.get("includeWatched", False)), - } + # 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() diff --git a/app/utils.py b/app/utils.py index afc3cbc..822cbaf 100644 --- a/app/utils.py +++ b/app/utils.py @@ -55,4 +55,5 @@ async def resolve_user_credentials(token: str) -> dict[str, Any]: "password": password, "authKey": auth_key, "includeWatched": include_watched, + "settings": payload.get("settings"), } diff --git a/static/index.html b/static/index.html index 1c4785c..97a0aee 100644 --- a/static/index.html +++ b/static/index.html @@ -18,12 +18,23 @@ colors: { slate: { 850: '#151f32', + 900: '#0f172a', + 950: '#020617', }, stremio: { DEFAULT: '#3b2667', hover: '#4e3286', border: '#573b94' } + }, + animation: { + 'fade-in': 'fadeIn 0.3s ease-out forwards', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0', transform: 'translateY(10px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + } } } } @@ -34,366 +45,468 @@ background-color: #020617; background-image: radial-gradient(circle at 50% 0%, rgba(30, 58, 138, 0.15), transparent 50%); } - - /* Custom scrollbar for webkit */ - ::-webkit-scrollbar { - width: 8px; + /* Custom scrollbar */ + ::-webkit-scrollbar { width: 8px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; } + ::-webkit-scrollbar-thumb:hover { background: #475569; } + + .nav-item.active { + @apply bg-blue-600/10 text-blue-400 border-l-2 border-blue-400; } - - ::-webkit-scrollbar-track { - background: transparent; - } - - ::-webkit-scrollbar-thumb { - background: #334155; - border-radius: 4px; - } - - ::-webkit-scrollbar-thumb:hover { - background: #475569; + .nav-item.disabled { + @apply opacity-50 cursor-not-allowed pointer-events-none; } -
-
-
+