Skip to content

Commit d51287e

Browse files
feat: Rework UI into a multi-step setup wizard with account management and section navigation (#22)
* feat: Rework UI into a multi-step setup wizard with Watchly account management and section navigation * critical: check if user already exists * refactor: remove disabled class from source code button
1 parent c7edd89 commit d51287e

File tree

6 files changed

+1119
-814
lines changed

6 files changed

+1119
-814
lines changed

app/api/endpoints/manifest.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,22 @@ def get_base_manifest(user_settings: UserSettings | None = None):
5454
}
5555

5656

57+
# Cache catalog definitions for 1 hour (3600s)
5758
# Cache catalog definitions for 1 hour (3600s)
5859
@alru_cache(maxsize=1000, ttl=3600)
5960
async def fetch_catalogs(token: str | None = None, settings_str: str | None = None):
6061
if not token:
6162
return []
6263

63-
user_settings = decode_settings(settings_str) if settings_str else None
64-
6564
credentials = await resolve_user_credentials(token)
65+
66+
if settings_str:
67+
user_settings = decode_settings(settings_str)
68+
elif credentials.get("settings"):
69+
user_settings = UserSettings(**credentials["settings"])
70+
else:
71+
user_settings = None
72+
6673
stremio_service = StremioService(
6774
username=credentials.get("username") or "",
6875
password=credentials.get("password") or "",
@@ -85,7 +92,18 @@ async def _manifest_handler(response: Response, token: str | None, settings_str:
8592
# Cache manifest for 1 day (86400 seconds)
8693
response.headers["Cache-Control"] = "public, max-age=86400"
8794

88-
user_settings = decode_settings(settings_str) if settings_str else None
95+
user_settings = None
96+
if settings_str:
97+
user_settings = decode_settings(settings_str)
98+
elif token:
99+
try:
100+
creds = await resolve_user_credentials(token)
101+
if creds.get("settings"):
102+
user_settings = UserSettings(**creds["settings"])
103+
except Exception:
104+
# Fallback to defaults if token resolution fails (or let it fail later in fetch_catalogs)
105+
pass
106+
89107
base_manifest = get_base_manifest(user_settings)
90108

91109
if token:

app/api/endpoints/tokens.py

Lines changed: 131 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from redis import exceptions as redis_exceptions
66

77
from app.core.config import settings
8-
from app.core.settings import CatalogConfig, UserSettings, encode_settings, get_default_settings
8+
from app.core.settings import CatalogConfig, UserSettings, get_default_settings
99
from app.services.catalog_updater import refresh_catalogs_for_credentials
1010
from app.services.stremio_service import StremioService
1111
from app.services.token_store import token_store
@@ -15,6 +15,8 @@
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")
1820
username: str | None = Field(default=None, description="Stremio username or email")
1921
password: str | None = Field(default=None, description="Stremio password")
2022
authKey: str | None = Field(default=None, description="Existing Stremio auth key")
@@ -77,34 +79,95 @@ async def _verify_credentials_or_raise(payload: dict) -> str:
7779

7880
@router.post("/", response_model=TokenResponse)
7981
async def create_token(payload: TokenRequest, request: Request) -> TokenResponse:
80-
username = payload.username.strip() if payload.username else None
81-
password = payload.password
82-
auth_key = payload.authKey.strip() if payload.authKey else None
82+
# Stremio Credentials
83+
stremio_username = payload.username.strip() if payload.username else None
84+
stremio_password = payload.password
85+
stremio_auth_key = payload.authKey.strip() if payload.authKey else None
86+
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
90+
8391
rpdb_key = payload.rpdb_key.strip() if payload.rpdb_key else None
8492

85-
if auth_key and auth_key.startswith('"') and auth_key.endswith('"'):
86-
auth_key = auth_key[1:-1].strip()
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()
95+
96+
# Construct Settings
97+
default_settings = get_default_settings()
8798

88-
if username and not password:
89-
raise HTTPException(status_code=400, detail="Password is required when a username is provided.")
99+
user_settings = UserSettings(
100+
language=payload.language or default_settings.language,
101+
catalogs=payload.catalogs if payload.catalogs else default_settings.catalogs,
102+
rpdb_key=rpdb_key,
103+
excluded_movie_genres=payload.excluded_movie_genres,
104+
excluded_series_genres=payload.excluded_series_genres,
105+
)
90106

91-
if password and not username:
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:
92141
raise HTTPException(
93142
status_code=400,
94-
detail="Username/email is required when a password is provided.",
143+
detail="Stremio username/email is required when password is provided.",
95144
)
96145

97-
if not auth_key and not (username and password):
146+
if not stremio_auth_key and not (stremio_username and stremio_password):
98147
raise HTTPException(
99148
status_code=400,
100-
detail="Provide either a Stremio auth key or both username and password.",
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}
101156
)
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+
)
102162

103-
# We only store credentials in Redis, settings go into URL
163+
# Payload to store includes BOTH Watchly and Stremio credentials + User Settings
104164
payload_to_store = {
105-
"username": username,
106-
"password": password,
107-
"authKey": auth_key,
165+
"watchly_username": watchly_username,
166+
"watchly_password": watchly_password,
167+
"username": stremio_username,
168+
"password": stremio_password,
169+
"authKey": stremio_auth_key,
170+
"settings": user_settings.model_dump(),
108171
}
109172

110173
verified_auth_key = await _verify_credentials_or_raise(payload_to_store)
@@ -125,20 +188,6 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
125188
detail="Token storage is temporarily unavailable. Please try again once Redis is reachable.",
126189
) from exc
127190

128-
# Construct Settings
129-
default_settings = get_default_settings()
130-
131-
user_settings = UserSettings(
132-
language=payload.language or default_settings.language,
133-
catalogs=payload.catalogs if payload.catalogs else default_settings.catalogs,
134-
rpdb_key=rpdb_key,
135-
excluded_movie_genres=payload.excluded_movie_genres,
136-
excluded_series_genres=payload.excluded_series_genres,
137-
)
138-
139-
# encode_settings now includes the "settings:" prefix
140-
encoded_settings = encode_settings(user_settings)
141-
142191
if created:
143192
try:
144193
await refresh_catalogs_for_credentials(
@@ -153,8 +202,8 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
153202
) from exc
154203

155204
base_url = settings.HOST_NAME
156-
# New URL structure
157-
manifest_url = f"{base_url}/{encoded_settings}/{token}/manifest.json"
205+
# New URL structure (Settings stored in Token)
206+
manifest_url = f"{base_url}/{token}/manifest.json"
158207

159208
expires_in = settings.TOKEN_TTL_SECONDS if settings.TOKEN_TTL_SECONDS > 0 else None
160209

@@ -165,26 +214,64 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
165214
)
166215

167216

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
222+
223+
if not watchly_username or not watchly_password:
224+
raise HTTPException(status_code=400, detail="Watchly username and password required.")
225+
226+
payload_to_derive = {
227+
"watchly_username": watchly_username,
228+
"watchly_password": watchly_password,
229+
"username": None,
230+
"password": None,
231+
"authKey": None,
232+
}
233+
234+
token = token_store.derive_token(payload_to_derive)
235+
exists = await token_store.get_payload(token)
236+
237+
if not exists:
238+
raise HTTPException(status_code=404, detail="Account not found.")
239+
240+
return {"found": True, "token": token, "settings": exists.get("settings")}
241+
242+
168243
@router.delete("/", status_code=200)
169244
async def delete_token(payload: TokenRequest):
170245
"""Delete a token based on provided credentials."""
171-
username = payload.username.strip() if payload.username else None
172-
password = payload.password
173-
auth_key = payload.authKey.strip() if payload.authKey else None
174-
175-
if auth_key and auth_key.startswith('"') and auth_key.endswith('"'):
176-
auth_key = auth_key[1:-1].strip()
177-
178-
if not auth_key and not (username and password):
246+
# Stremio Credentials
247+
stremio_username = payload.username.strip() if payload.username else None
248+
stremio_password = payload.password
249+
stremio_auth_key = payload.authKey.strip() if payload.authKey else None
250+
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+
):
179264
raise HTTPException(
180265
status_code=400,
181-
detail="Provide either a Stremio auth key or both username and password to delete account.",
266+
detail="Provide Watchly credentials (or Stremio credentials for legacy accounts) to delete account.",
182267
)
183268

184269
payload_to_derive = {
185-
"username": username,
186-
"password": password,
187-
"authKey": auth_key,
270+
"watchly_username": watchly_username,
271+
"watchly_password": watchly_password,
272+
"username": stremio_username,
273+
"password": stremio_password,
274+
"authKey": stremio_auth_key,
188275
}
189276

190277
try:

app/services/token_store.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,31 @@ def _format_key(self, hashed_token: str) -> str:
6363

6464
def _normalize_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
6565
return {
66+
"watchly_username": (payload.get("watchly_username") or "").strip() or None,
67+
"watchly_password": payload.get("watchly_password") or None,
6668
"username": (payload.get("username") or "").strip() or None,
6769
"password": payload.get("password") or None,
6870
"authKey": (payload.get("authKey") or "").strip() or None,
6971
"includeWatched": bool(payload.get("includeWatched", False)),
72+
"settings": payload.get("settings"),
7073
}
7174

7275
def _derive_token_value(self, payload: dict[str, Any]) -> str:
73-
canonical = {
74-
"username": payload.get("username") or "",
75-
"password": payload.get("password") or "",
76-
"authKey": payload.get("authKey") or "",
77-
"includeWatched": bool(payload.get("includeWatched", False)),
78-
}
76+
# Prioritize Watchly credentials for stable token generation
77+
if payload.get("watchly_username"):
78+
canonical = {
79+
"watchly_username": payload.get("watchly_username"),
80+
"watchly_password": payload.get("watchly_password") or "",
81+
}
82+
else:
83+
# Legacy fallback
84+
canonical = {
85+
"username": payload.get("username") or "",
86+
"password": payload.get("password") or "",
87+
"authKey": payload.get("authKey") or "",
88+
"includeWatched": bool(payload.get("includeWatched", False)),
89+
}
90+
7991
serialized = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
8092
secret = settings.TOKEN_SALT.encode("utf-8")
8193
return hmac.new(secret, serialized.encode("utf-8"), hashlib.sha256).hexdigest()

app/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,5 @@ async def resolve_user_credentials(token: str) -> dict[str, Any]:
5555
"password": password,
5656
"authKey": auth_key,
5757
"includeWatched": include_watched,
58+
"settings": payload.get("settings"),
5859
}

0 commit comments

Comments
 (0)