Skip to content

Commit 82b2346

Browse files
feat: Implement user ID-based token management and Stremio user info fetching, replacing credential-derived tokens and direct login. (#25)
1 parent 77c4fa2 commit 82b2346

File tree

7 files changed

+849
-656
lines changed

7 files changed

+849
-656
lines changed

app/api/endpoints/catalogs.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import re
2+
13
from fastapi import APIRouter, HTTPException, Response
24
from loguru import logger
35

@@ -38,9 +40,8 @@ async def get_catalog(
3840
raise HTTPException(status_code=400, detail="Invalid type. Use 'movie' or 'series'")
3941

4042
# Supported IDs now include dynamic themes and item-based rows
41-
if (
42-
id != "watchly.rec"
43-
and not any(id.startswith(p) for p in ("tt", "watchly.theme.", "watchly.item.", "watchly.loved.", "watchly.watched."))
43+
if id != "watchly.rec" and not any(
44+
id.startswith(p) for p in ("tt", "watchly.theme.", "watchly.item.", "watchly.loved.", "watchly.watched.")
4445
):
4546
logger.warning(f"Invalid id: {id}")
4647
raise HTTPException(

app/api/endpoints/tokens.py

Lines changed: 107 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,7 @@
1515

1616

1717
class TokenRequest(BaseModel):
18-
watchly_username: str | None = Field(default=None, description="Watchly account (user/id)")
19-
watchly_password: str | None = Field(default=None, description="Watchly account password")
20-
username: str | None = Field(default=None, description="Stremio username or email")
21-
password: str | None = Field(default=None, description="Stremio password")
22-
authKey: str | None = Field(default=None, description="Existing Stremio auth key")
18+
authKey: str | None = Field(default=None, description="Stremio auth key")
2319
catalogs: list[CatalogConfig] | None = Field(default=None, description="Optional catalog configuration")
2420
language: str = Field(default="en-US", description="Language for TMDB API")
2521
rpdb_key: str | None = Field(default=None, description="Optional RPDB API Key")
@@ -38,18 +34,13 @@ class TokenResponse(BaseModel):
3834

3935
async def _verify_credentials_or_raise(payload: dict) -> str:
4036
"""Ensure the supplied credentials/auth key are valid before issuing tokens."""
41-
stremio_service = StremioService(
42-
username=payload.get("username") or "",
43-
password=payload.get("password") or "",
44-
auth_key=payload.get("authKey"),
45-
)
37+
stremio_service = StremioService(auth_key=payload.get("authKey"))
4638

4739
try:
48-
if payload.get("authKey") and not payload.get("username"):
40+
if payload.get("authKey"):
4941
await stremio_service.get_addons(auth_key=payload["authKey"])
5042
return payload["authKey"]
51-
auth_key = await stremio_service.get_auth_key()
52-
return auth_key
43+
raise ValueError("Please Login using stremio account to continue!")
5344
except ValueError as exc:
5445
raise HTTPException(
5546
status_code=400,
@@ -79,21 +70,33 @@ async def _verify_credentials_or_raise(payload: dict) -> str:
7970

8071
@router.post("/", response_model=TokenResponse)
8172
async def create_token(payload: TokenRequest, request: Request) -> TokenResponse:
82-
# Stremio Credentials
83-
stremio_username = payload.username.strip() if payload.username else None
84-
stremio_password = payload.password
8573
stremio_auth_key = payload.authKey.strip() if payload.authKey else None
8674

87-
# Watchly Credentials (The new flow)
88-
watchly_username = payload.watchly_username.strip() if payload.watchly_username else None
89-
watchly_password = payload.watchly_password
75+
if not stremio_auth_key:
76+
raise HTTPException(status_code=400, detail="Stremio auth key is required.")
77+
78+
# Remove quotes if present
79+
if stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'):
80+
stremio_auth_key = stremio_auth_key[1:-1].strip()
9081

9182
rpdb_key = payload.rpdb_key.strip() if payload.rpdb_key else None
9283

93-
if stremio_auth_key and stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'):
94-
stremio_auth_key = stremio_auth_key[1:-1].strip()
84+
# 1. Fetch user info from Stremio (user_id and email)
85+
stremio_service = StremioService(auth_key=stremio_auth_key)
86+
try:
87+
user_info = await stremio_service.get_user_info()
88+
user_id = user_info["user_id"]
89+
email = user_info.get("email", "")
90+
except Exception as e:
91+
raise HTTPException(status_code=400, detail=f"Failed to verify Stremio identity: {e}")
92+
finally:
93+
await stremio_service.close()
9594

96-
# Construct Settings
95+
# 2. Check if user already exists
96+
token = token_store.get_token_from_user_id(user_id)
97+
existing_data = await token_store.get_user_data(token)
98+
99+
# 3. Construct Settings
97100
default_settings = get_default_settings()
98101

99102
user_settings = UserSettings(
@@ -104,107 +107,44 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
104107
excluded_series_genres=payload.excluded_series_genres,
105108
)
106109

107-
# Logic to handle "Update Mode" (Watchly credentials only)
108-
is_update_mode = (watchly_username and watchly_password) and not (
109-
stremio_username or stremio_password or stremio_auth_key
110-
)
111-
112-
if is_update_mode:
113-
# User is trying to update settings using only Watchly credentials
114-
# We must retrieve their existing Stremio credentials from the store
115-
temp_payload_for_derivation = {
116-
"watchly_username": watchly_username,
117-
"watchly_password": watchly_password,
118-
"username": None,
119-
"password": None,
120-
"authKey": None,
121-
}
122-
derived_token = token_store.derive_token(temp_payload_for_derivation)
123-
existing_data = await token_store.get_payload(derived_token)
124-
125-
if not existing_data:
126-
raise HTTPException(
127-
status_code=404,
128-
detail="Account not found. Please start as a New User to connect Stremio.",
129-
)
130-
131-
# Hydrate Stremio credentials from existing data
132-
stremio_username = existing_data.get("username")
133-
stremio_password = existing_data.get("password")
134-
stremio_auth_key = existing_data.get("authKey")
135-
136-
# Regular Validation Logic
137-
if stremio_username and not stremio_password:
138-
raise HTTPException(status_code=400, detail="Stremio password is required when username is provided.")
139-
140-
if stremio_password and not stremio_username:
141-
raise HTTPException(
142-
status_code=400,
143-
detail="Stremio username/email is required when password is provided.",
144-
)
145-
146-
if not stremio_auth_key and not (stremio_username and stremio_password):
147-
raise HTTPException(
148-
status_code=400,
149-
detail="Provide either a Stremio auth key or both Stremio username and password.",
150-
)
151-
152-
# if creating a new account, check if the Watchly ID is already taken.
153-
if watchly_username and not is_update_mode:
154-
derived_token = token_store.derive_token(
155-
{"watchly_username": watchly_username, "watchly_password": watchly_password}
156-
)
157-
if await token_store.get_payload(derived_token):
158-
raise HTTPException(
159-
status_code=409,
160-
detail="This Watchly ID is already in use. Please choose a different one or log in as an Existing User.", # noqa: E501
161-
)
162-
163-
# Payload to store includes BOTH Watchly and Stremio credentials + User Settings
110+
# 4. Prepare payload to store
164111
payload_to_store = {
165-
"watchly_username": watchly_username,
166-
"watchly_password": watchly_password,
167-
"username": stremio_username,
168-
"password": stremio_password,
169112
"authKey": stremio_auth_key,
113+
"email": email,
170114
"settings": user_settings.model_dump(),
171115
}
172116

173-
verified_auth_key = await _verify_credentials_or_raise(payload_to_store)
117+
is_new_account = not existing_data
174118

119+
# 5. Verify Stremio connection
120+
verified_auth_key = await _verify_credentials_or_raise({"authKey": stremio_auth_key})
121+
122+
# 6. Store user data
175123
try:
176-
token, created = await token_store.store_payload(payload_to_store)
177-
logger.info(f"[{redact_token(token)}] Token {'created' if created else 'updated'}")
124+
token = await token_store.store_user_data(user_id, payload_to_store)
125+
logger.info(f"[{redact_token(token)}] Account {'created' if is_new_account else 'updated'} for user {user_id}")
178126
except RuntimeError as exc:
179-
logger.error("Token storage failed: {}", exc)
180-
raise HTTPException(
181-
status_code=500,
182-
detail="Server configuration error: TOKEN_SALT must be set to a secure value.",
183-
) from exc
127+
raise HTTPException(status_code=500, detail="Server configuration error.") from exc
184128
except (redis_exceptions.RedisError, OSError) as exc:
185-
logger.error("Token storage unavailable: {}", exc)
186-
raise HTTPException(
187-
status_code=503,
188-
detail="Token storage is temporarily unavailable. Please try again once Redis is reachable.",
189-
) from exc
129+
raise HTTPException(status_code=503, detail="Storage temporarily unavailable.") from exc
190130

191-
if created:
192-
try:
193-
await refresh_catalogs_for_credentials(
194-
payload_to_store, user_settings=user_settings, auth_key=verified_auth_key
195-
)
196-
except Exception as exc: # pragma: no cover - remote dependency
197-
logger.error(f"[{redact_token(token)}] Initial catalog refresh failed: {{}}", exc, exc_info=True)
198-
await token_store.delete_token(token=token)
131+
# 7. Refresh Catalogs
132+
try:
133+
await refresh_catalogs_for_credentials(
134+
payload_to_store, user_settings=user_settings, auth_key=verified_auth_key
135+
)
136+
except Exception as exc:
137+
logger.error(f"Catalog refresh failed: {exc}")
138+
if is_new_account:
139+
# Rollback on new account creation failure
140+
await token_store.delete_token(token)
199141
raise HTTPException(
200142
status_code=502,
201-
detail="Credentials verified, but Watchly couldn't refresh your catalogs yet. Please try again.",
143+
detail="Credentials verified, but catalog refresh failed. Please try again.",
202144
) from exc
203145

204146
base_url = settings.HOST_NAME
205-
# New URL structure (Settings stored in Token)
206147
manifest_url = f"{base_url}/{token}/manifest.json"
207-
208148
expires_in = settings.TOKEN_TTL_SECONDS if settings.TOKEN_TTL_SECONDS > 0 else None
209149

210150
return TokenResponse(
@@ -214,78 +154,85 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
214154
)
215155

216156

217-
@router.post("/verify", status_code=200)
218-
async def verify_user(payload: TokenRequest):
219-
"""Verify if a Watchly user exists."""
220-
watchly_username = payload.watchly_username.strip() if payload.watchly_username else None
221-
watchly_password = payload.watchly_password
157+
@router.post("/stremio-identity", status_code=200)
158+
async def check_stremio_identity(payload: TokenRequest):
159+
"""Fetch user info from Stremio and check if account exists."""
160+
auth_key = payload.authKey.strip() if payload.authKey else None
222161

223-
if not watchly_username or not watchly_password:
224-
raise HTTPException(status_code=400, detail="Watchly username and password required.")
162+
if not auth_key:
163+
raise HTTPException(status_code=400, detail="Auth Key required.")
225164

226-
payload_to_derive = {
227-
"watchly_username": watchly_username,
228-
"watchly_password": watchly_password,
229-
"username": None,
230-
"password": None,
231-
"authKey": None,
232-
}
165+
if auth_key.startswith('"') and auth_key.endswith('"'):
166+
auth_key = auth_key[1:-1].strip()
233167

234-
token = token_store.derive_token(payload_to_derive)
235-
exists = await token_store.get_payload(token)
168+
stremio_service = StremioService(auth_key=auth_key)
169+
try:
170+
user_info = await stremio_service.get_user_info()
171+
user_id = user_info["user_id"]
172+
email = user_info.get("email", "")
173+
except Exception as e:
174+
logger.error(f"Stremio identity check failed: {e}")
175+
raise HTTPException(
176+
status_code=400, detail="Failed to verify Stremio identity. Your auth key might be invalid or expired."
177+
)
178+
finally:
179+
await stremio_service.close()
180+
181+
# Check existence
182+
try:
183+
token = token_store.get_token_from_user_id(user_id)
184+
user_data = await token_store.get_user_data(token)
185+
exists = bool(user_data)
186+
except ValueError:
187+
exists = False
188+
user_data = None
236189

237-
if not exists:
238-
raise HTTPException(status_code=404, detail="Account not found.")
190+
response = {"user_id": user_id, "email": email, "exists": exists}
191+
if exists and user_data:
192+
response["settings"] = user_data.get("settings")
239193

240-
return {"found": True, "token": token, "settings": exists.get("settings")}
194+
return response
241195

242196

243197
@router.delete("/", status_code=200)
244198
async def delete_token(payload: TokenRequest):
245-
"""Delete a token based on provided credentials."""
246-
# Stremio Credentials
247-
stremio_username = payload.username.strip() if payload.username else None
248-
stremio_password = payload.password
199+
"""Delete a token based on Stremio auth key."""
249200
stremio_auth_key = payload.authKey.strip() if payload.authKey else None
250201

251-
# Watchly Credentials
252-
watchly_username = payload.watchly_username.strip() if payload.watchly_username else None
253-
watchly_password = payload.watchly_password
254-
255-
if stremio_auth_key and stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'):
256-
stremio_auth_key = stremio_auth_key[1:-1].strip()
257-
258-
# Need either Watchly creds OR Stremio creds (for legacy)
259-
if (
260-
not (watchly_username and watchly_password)
261-
and not stremio_auth_key
262-
and not (stremio_username and stremio_password)
263-
):
202+
if not stremio_auth_key:
264203
raise HTTPException(
265204
status_code=400,
266-
detail="Provide Watchly credentials (or Stremio credentials for legacy accounts) to delete account.",
205+
detail="Stremio auth key is required to delete account.",
267206
)
268207

269-
payload_to_derive = {
270-
"watchly_username": watchly_username,
271-
"watchly_password": watchly_password,
272-
"username": stremio_username,
273-
"password": stremio_password,
274-
"authKey": stremio_auth_key,
275-
}
208+
if stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'):
209+
stremio_auth_key = stremio_auth_key[1:-1].strip()
276210

277211
try:
278-
# We don't verify credentials with Stremio here, we just check if we have a token for them.
279-
# If the user provides wrong credentials, we'll derive a wrong token, which won't exist in Redis.
280-
# That's fine, we can just say "deleted" or "not found".
281-
# However, to be nice, we might want to say "Settings deleted" even if they didn't exist.
282-
# But if we want to be strict, we could check existence.
283-
# Let's just try to delete.
284-
285-
token = token_store.derive_token(payload_to_derive)
212+
# Fetch user info from Stremio
213+
stremio_service = StremioService(auth_key=stremio_auth_key)
214+
try:
215+
user_info = await stremio_service.get_user_info()
216+
user_id = user_info["user_id"]
217+
except Exception as e:
218+
raise HTTPException(status_code=400, detail=f"Failed to verify Stremio identity: {e}")
219+
finally:
220+
await stremio_service.close()
221+
222+
# Get token from user_id
223+
token = token_store.get_token_from_user_id(user_id)
224+
225+
# Verify account exists
226+
existing_data = await token_store.get_user_data(token)
227+
if not existing_data:
228+
raise HTTPException(status_code=404, detail="Account not found.")
229+
230+
# Delete the token
286231
await token_store.delete_token(token)
287-
logger.info(f"[{redact_token(token)}] Token deleted (if existed)")
232+
logger.info(f"[{redact_token(token)}] Token deleted for user {user_id}")
288233
return {"detail": "Settings deleted successfully"}
234+
except HTTPException:
235+
raise
289236
except (redis_exceptions.RedisError, OSError) as exc:
290237
logger.error("Token deletion failed: {}", exc)
291238
raise HTTPException(

app/core/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
RECOMMENDATIONS_CATALOG_NAME: str = "Top Picks For You"

0 commit comments

Comments
 (0)