Skip to content

Commit 837d531

Browse files
feat: add option to login using email/password (#60)
1 parent 39c08f4 commit 837d531

File tree

11 files changed

+413
-97
lines changed

11 files changed

+413
-97
lines changed

app/api/endpoints/catalogs.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,29 @@
1919
router = APIRouter()
2020

2121

22+
def _clean_meta(meta: dict) -> dict:
23+
"""Return a sanitized Stremio meta object without internal fields.
24+
25+
Keeps only public keys and drops internal scoring/IDs/keywords/cast, etc.
26+
"""
27+
allowed = {
28+
"id",
29+
"type",
30+
"name",
31+
"poster",
32+
"background",
33+
"description",
34+
"releaseInfo",
35+
"imdbRating",
36+
"genres",
37+
"runtime",
38+
}
39+
cleaned = {k: v for k, v in meta.items() if k in allowed}
40+
# Drop empty values
41+
cleaned = {k: v for k, v in cleaned.items() if v not in (None, "", [], {}, ())}
42+
return cleaned
43+
44+
2245
@router.get("/{token}/catalog/{type}/{id}.json")
2346
async def get_catalog(type: str, id: str, response: Response, token: str):
2447
if not token:
@@ -62,8 +85,17 @@ async def get_catalog(type: str, id: str, response: Response, token: str):
6285
user_settings = UserSettings(**settings_dict) if settings_dict else get_default_settings()
6386
language = user_settings.language if user_settings else "en-US"
6487

65-
# Create services with credentials
66-
stremio_service = StremioService(auth_key=credentials.get("authKey"))
88+
# Create services with credentials (prefer fresh login with email/password)
89+
if credentials.get("password") and credentials.get("email"):
90+
stremio_service = StremioService(
91+
username=credentials.get("email", ""), password=credentials.get("password", "")
92+
)
93+
try:
94+
await stremio_service._login_for_auth_key()
95+
except Exception:
96+
stremio_service = StremioService(auth_key=credentials.get("authKey"))
97+
else:
98+
stremio_service = StremioService(auth_key=credentials.get("authKey"))
6799
# Fetch library once per request and reuse across recommendation paths
68100
library_items = await stremio_service.get_library_items()
69101
recommendation_service = RecommendationService(
@@ -95,7 +127,8 @@ def _get_limits() -> tuple[int, int]:
95127
max_items = max(min_items, min(DEFAULT_MAX_ITEMS, int(max_items)))
96128
except (ValueError, TypeError):
97129
logger.warning(
98-
f"Invalid min/max items values. Falling back to defaults. min_items={min_items}, max_items={max_items}"
130+
"Invalid min/max items values. Falling back to defaults. "
131+
f"min_items={min_items}, max_items={max_items}"
99132
)
100133
min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
101134

@@ -148,7 +181,8 @@ def _get_limits() -> tuple[int, int]:
148181
logger.info(f"Returning {len(recommendations)} items for {type}")
149182
# Avoid serving stale results; revalidate on each request
150183
response.headers["Cache-Control"] = "no-cache"
151-
return {"metas": recommendations}
184+
cleaned = [_clean_meta(m) for m in recommendations]
185+
return {"metas": cleaned}
152186

153187
except HTTPException:
154188
raise

app/api/endpoints/manifest.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from fastapi import HTTPException, Response
22
from fastapi.routing import APIRouter
3+
from loguru import logger
34

45
from app.core.config import settings
56
from app.core.settings import UserSettings, get_default_settings
@@ -96,8 +97,24 @@ async def _manifest_handler(response: Response, token: str):
9697
base_manifest = get_base_manifest(user_settings)
9798

9899
# Build dynamic catalogs using the already-fetched credentials
99-
stremio_service = StremioService(auth_key=creds.get("authKey"))
100-
fetched_catalogs = await build_dynamic_catalogs(stremio_service, user_settings or get_default_settings())
100+
# Build Stremio service with fresh auth when possible
101+
if creds.get("password") and creds.get("email"):
102+
stremio_service = StremioService(username=creds.get("email", ""), password=creds.get("password", ""))
103+
try:
104+
await stremio_service._login_for_auth_key()
105+
except Exception:
106+
# fallback to stored authKey if login fails
107+
stremio_service = StremioService(auth_key=creds.get("authKey"))
108+
else:
109+
stremio_service = StremioService(auth_key=creds.get("authKey"))
110+
try:
111+
fetched_catalogs = await build_dynamic_catalogs(
112+
stremio_service,
113+
user_settings or get_default_settings(),
114+
)
115+
except Exception as e:
116+
logger.warning(f"Dynamic catalog build failed: {e}")
117+
fetched_catalogs = []
101118

102119
all_catalogs = [c.copy() for c in base_manifest["catalogs"]] + [c.copy() for c in fetched_catalogs]
103120

@@ -107,7 +124,11 @@ async def _manifest_handler(response: Response, token: str):
107124
if user_settings and user_settings.language:
108125
for cat in all_catalogs:
109126
if cat.get("name"):
110-
cat["name"] = await translation_service.translate(cat["name"], user_settings.language)
127+
try:
128+
cat["name"] = await translation_service.translate(cat["name"], user_settings.language)
129+
except Exception as e:
130+
# On translation failure, keep original name and log the error
131+
logger.warning(f"Failed to translate catalog name '{cat.get('name')}': {e}")
111132
translated_catalogs.append(cat)
112133
else:
113134
translated_catalogs = all_catalogs
@@ -116,7 +137,27 @@ async def _manifest_handler(response: Response, token: str):
116137
order_map = {c.id: i for i, c in enumerate(user_settings.catalogs)}
117138
translated_catalogs.sort(key=lambda x: order_map.get(get_config_id(x), 999))
118139

119-
base_manifest["catalogs"] = translated_catalogs
140+
# Safety fallback respecting user settings:
141+
# - If the final list is empty AND user's base config allows 'watchly.rec',
142+
# expose the base recommendation rows (so users don't see an empty addon).
143+
# - If the user explicitly disabled 'watchly.rec' (or disabled all rows),
144+
# DO NOT add fallback rows; keep it empty to honor their choice.
145+
if not translated_catalogs:
146+
fallback_base = get_base_manifest(user_settings)
147+
if fallback_base.get("catalogs"):
148+
base_manifest["catalogs"] = fallback_base["catalogs"]
149+
else:
150+
base_manifest["catalogs"] = []
151+
else:
152+
base_manifest["catalogs"] = translated_catalogs
153+
154+
# Debug headers (counts) to help diagnose empty-manifest issues in production
155+
try:
156+
response.headers["X-Base-Catalogs"] = str(len(get_base_manifest(user_settings)["catalogs"]))
157+
response.headers["X-Dynamic-Catalogs"] = str(len(fetched_catalogs))
158+
response.headers["X-Final-Catalogs"] = str(len(base_manifest.get("catalogs", [])))
159+
except Exception as e:
160+
logger.warning(f"Failed to set debug headers: {e}")
120161

121162
return base_manifest
122163

app/api/endpoints/stats.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from fastapi import APIRouter
2+
from loguru import logger
3+
4+
from app.services.token_store import token_store
5+
6+
router = APIRouter()
7+
8+
9+
@router.get("/stats")
10+
async def get_stats() -> dict:
11+
"""Return lightweight public stats for the homepage.
12+
13+
Total users is cached for 12 hours inside TokenStore to avoid heavy scans.
14+
"""
15+
try:
16+
total = await token_store.count_users()
17+
except Exception as exc:
18+
logger.warning(f"Failed to get total users: {exc}")
19+
total = 0
20+
return {"total_users": total}

app/api/endpoints/tokens.py

Lines changed: 79 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
class TokenRequest(BaseModel):
1717
authKey: str | None = Field(default=None, description="Stremio auth key")
18+
email: str | None = Field(default=None, description="Stremio account email")
19+
password: str | None = Field(default=None, description="Stremio account password (stored securely)")
1820
catalogs: list[CatalogConfig] | None = Field(default=None, description="Optional catalog configuration")
1921
language: str = Field(default="en-US", description="Language for TMDB API")
2022
rpdb_key: str | None = Field(default=None, description="Optional RPDB API Key")
@@ -69,27 +71,44 @@ async def _verify_credentials_or_raise(payload: dict) -> str:
6971

7072
@router.post("/", response_model=TokenResponse)
7173
async def create_token(payload: TokenRequest, request: Request) -> TokenResponse:
72-
stremio_auth_key = payload.authKey.strip() if payload.authKey else None
74+
# Prefer email+password if provided; else require authKey
75+
email = (payload.email or "").strip() or None
76+
password = (payload.password or "").strip() or None
77+
stremio_auth_key = (payload.authKey or "").strip() or None
7378

74-
if not stremio_auth_key:
75-
raise HTTPException(status_code=400, detail="Stremio auth key is required.")
79+
if not (email and password) and not stremio_auth_key:
80+
raise HTTPException(status_code=400, detail="Provide email+password or a valid Stremio auth key.")
7681

77-
# Remove quotes if present
78-
if stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'):
82+
# Remove quotes if present for authKey
83+
if stremio_auth_key and stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'):
7984
stremio_auth_key = stremio_auth_key[1:-1].strip()
8085

8186
rpdb_key = payload.rpdb_key.strip() if payload.rpdb_key else None
8287

83-
# 1. Fetch user info from Stremio (user_id and email)
84-
stremio_service = StremioService(auth_key=stremio_auth_key)
85-
try:
86-
user_info = await stremio_service.get_user_info()
87-
user_id = user_info["user_id"]
88-
email = user_info.get("email", "")
89-
except Exception as e:
90-
raise HTTPException(status_code=400, detail=f"Failed to verify Stremio identity: {e}")
91-
finally:
92-
await stremio_service.close()
88+
# 1. Establish a valid auth key and fetch user info
89+
if email and password:
90+
stremio_service = StremioService(username=email, password=password)
91+
try:
92+
# Always get a fresh key
93+
fresh_key = await stremio_service._login_for_auth_key()
94+
stremio_auth_key = fresh_key
95+
user_info = await stremio_service.get_user_info()
96+
user_id = user_info["user_id"]
97+
resolved_email = user_info.get("email", email)
98+
except Exception as e:
99+
raise HTTPException(status_code=400, detail=f"Failed to verify Stremio identity: {e}")
100+
finally:
101+
await stremio_service.close()
102+
else:
103+
stremio_service = StremioService(auth_key=stremio_auth_key)
104+
try:
105+
user_info = await stremio_service.get_user_info()
106+
user_id = user_info["user_id"]
107+
resolved_email = user_info.get("email", "")
108+
except Exception as e:
109+
raise HTTPException(status_code=400, detail=f"Failed to verify Stremio identity: {e}")
110+
finally:
111+
await stremio_service.close()
93112

94113
# 2. Check if user already exists
95114
token = token_store.get_token_from_user_id(user_id)
@@ -109,20 +128,30 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
109128
is_new_account = not existing_data
110129

111130
# 4. Verify Stremio connection
112-
verified_auth_key = await _verify_credentials_or_raise({"authKey": stremio_auth_key})
131+
# Already verified above. For authKey path, still validate to catch expired keys
132+
if not (email and password):
133+
verified_auth_key = await _verify_credentials_or_raise({"authKey": stremio_auth_key})
134+
else:
135+
verified_auth_key = stremio_auth_key
113136

114137
# 5. Prepare payload to store
115138
payload_to_store = {
116139
"authKey": verified_auth_key,
117-
"email": email,
140+
"email": resolved_email or email or "",
118141
"settings": user_settings.model_dump(),
119142
}
143+
# Store password if provided so we can refresh authKey later without user action
144+
if email and password:
145+
payload_to_store["password"] = password
120146

121147
# 6. Store user data
122148
try:
123149
token = await token_store.store_user_data(user_id, payload_to_store)
124150
logger.info(f"[{redact_token(token)}] Account {'created' if is_new_account else 'updated'} for user {user_id}")
125151
except RuntimeError as exc:
152+
# Surface a clear message when secure storage fails
153+
if "PASSWORD_ENCRYPT_FAILED" in str(exc):
154+
raise HTTPException(status_code=500, detail="Secure storage failed. Please log in again.") from exc
126155
raise HTTPException(status_code=500, detail="Server configuration error.") from exc
127156
except (redis_exceptions.RedisError, OSError) as exc:
128157
raise HTTPException(status_code=503, detail="Storage temporarily unavailable.") from exc
@@ -139,27 +168,38 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
139168

140169

141170
async def get_stremio_user_data(payload: TokenRequest) -> tuple[str, str]:
142-
auth_key = payload.authKey.strip() if payload.authKey else None
143-
144-
if not auth_key:
145-
raise HTTPException(status_code=400, detail="Auth Key required.")
146-
147-
if auth_key.startswith('"') and auth_key.endswith('"'):
148-
auth_key = auth_key[1:-1].strip()
149-
150-
stremio_service = StremioService(auth_key=auth_key)
151-
try:
152-
user_info = await stremio_service.get_user_info()
153-
user_id = user_info["user_id"]
154-
email = user_info.get("email", "")
155-
return user_id, email
156-
except Exception as e:
157-
logger.error(f"Stremio identity check failed: {e}")
158-
raise HTTPException(
159-
status_code=400, detail="Failed to verify Stremio identity. Your auth key might be invalid or expired."
160-
)
161-
finally:
162-
await stremio_service.close()
171+
email = (payload.email or "").strip() or None
172+
password = (payload.password or "").strip() or None
173+
auth_key = (payload.authKey or "").strip() or None
174+
175+
if email and password:
176+
svc = StremioService(username=email, password=password)
177+
try:
178+
await svc._login_for_auth_key()
179+
user_info = await svc.get_user_info()
180+
return user_info["user_id"], user_info.get("email", email)
181+
except Exception as e:
182+
logger.error(f"Stremio identity check failed (email/password): {e}")
183+
raise HTTPException(status_code=400, detail="Failed to verify Stremio identity with email/password.")
184+
finally:
185+
await svc.close()
186+
elif auth_key:
187+
if auth_key.startswith('"') and auth_key.endswith('"'):
188+
auth_key = auth_key[1:-1].strip()
189+
svc = StremioService(auth_key=auth_key)
190+
try:
191+
user_info = await svc.get_user_info()
192+
return user_info["user_id"], user_info.get("email", "")
193+
except Exception as e:
194+
logger.error(f"Stremio identity check failed: {e}")
195+
raise HTTPException(
196+
status_code=400,
197+
detail="Failed to verify Stremio identity. Your auth key might be invalid or expired.",
198+
)
199+
finally:
200+
await svc.close()
201+
else:
202+
raise HTTPException(status_code=400, detail="Provide email+password or auth key.")
163203

164204

165205
@router.post("/stremio-identity", status_code=200)
@@ -183,7 +223,7 @@ async def check_stremio_identity(payload: TokenRequest):
183223

184224

185225
@router.delete("/", status_code=200)
186-
async def delete_token(payload: TokenRequest):
226+
async def delete_redis_token(payload: TokenRequest):
187227
"""Delete a token based on Stremio auth key."""
188228
try:
189229
user_id, _ = await get_stremio_user_data(payload)

app/api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .endpoints.health import router as health_router
66
from .endpoints.manifest import router as manifest_router
77
from .endpoints.meta import router as meta_router
8+
from .endpoints.stats import router as stats_router
89
from .endpoints.tokens import router as tokens_router
910

1011
api_router = APIRouter()
@@ -21,3 +22,4 @@ async def root():
2122
api_router.include_router(health_router)
2223
api_router.include_router(meta_router)
2324
api_router.include_router(announcement_router)
25+
api_router.include_router(stats_router)

app/core/app.py

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import asyncio
21
import os
32
from contextlib import asynccontextmanager
43
from pathlib import Path
@@ -13,23 +12,10 @@
1312
from app.api.main import api_router
1413
from app.services.catalog_updater import BackgroundCatalogUpdater
1514
from app.services.token_store import token_store
16-
from app.startup.migration import migrate_tokens
1715

1816
from .config import settings
1917
from .version import __version__
2018

21-
# class InterceptHandler(logging.Handler):
22-
# def emit(self, record):
23-
# try:
24-
# level = logger.level(record.levelname).name
25-
# except Exception:
26-
# level = record.levelno
27-
28-
# logger.opt(depth=6, exception=record.exc_info).log(level, record.getMessage())
29-
30-
31-
# logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO, force=True)
32-
3319
# Global catalog updater instance
3420
catalog_updater: BackgroundCatalogUpdater | None = None
3521

@@ -40,19 +26,6 @@ async def lifespan(app: FastAPI):
4026
Manage application lifespan events (startup/shutdown).
4127
"""
4228
global catalog_updater
43-
44-
if settings.HOST_NAME.lower() != "https://1ccea4301587-watchly.baby-beamup.club":
45-
task = asyncio.create_task(migrate_tokens())
46-
47-
# Ensure background exceptions are surfaced in logs
48-
def _on_done(t: asyncio.Task):
49-
try:
50-
t.result()
51-
except Exception as exc:
52-
logger.error(f"migrate_tokens background task failed: {exc}")
53-
54-
task.add_done_callback(_on_done)
55-
5629
# Startup
5730
if settings.AUTO_UPDATE_CATALOGS:
5831
catalog_updater = BackgroundCatalogUpdater()

0 commit comments

Comments
 (0)