Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/api/endpoints/catalogs.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

from fastapi import APIRouter, HTTPException, Response
from loguru import logger

Expand Down Expand Up @@ -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(
Expand Down
267 changes: 107 additions & 160 deletions app/api/endpoints/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions app/core/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RECOMMENDATIONS_CATALOG_NAME: str = "Top Picks For You"
Loading