Skip to content

Commit 8342c9a

Browse files
refactor: code refactoring and remove legacy code and components (#26)
* feat: add option to rename, enable disable catalogs * feat: add option to rename, enable disable catalogs * feat: Implement user ID-based token management and Stremio user info fetching, replacing credential-derived tokens and direct login. * feat: Implement user ID-based token management and Stremio user info fetching, replacing credential-derived tokens and direct login. * feat: Refactor token management to use a dedicated token model, remove token salt, and streamline catalog refresh logic. * feat: Encrypt auth keys in token store and enforce 401 for invalid tokens across endpoints. * feat: Introduce token redaction utility and apply it to log messages for enhanced security. * feat: Introduce token redaction utility and apply it to log messages for enhanced security.
1 parent 82b2346 commit 8342c9a

File tree

13 files changed

+173
-329
lines changed

13 files changed

+173
-329
lines changed

.env.example

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ PORT=8000
33
ADDON_ID=com.bimal.watchly
44
ADDON_NAME=Watchly
55
REDIS_URL=redis://redis:6379/0
6-
TOKEN_SALT=replace-with-a-long-random-string
76
TOKEN_TTL_SECONDS=0
87
ANNOUNCEMENT_HTML=
98
HOST_NAME=<your_addon_url>
109
RECOMMENDATION_SOURCE_ITEMS_LIMIT=10 # fetches recent watched/loved 10 movies and series to recommend based on those
11-
10+
TOKEN_SALT=change-me
11+
# generate some very long random string preferrably using cryptography libraries
1212
# UPDATER
13-
CATALOG_UPDATE_MODE=cron
13+
CATALOG_UPDATE_MODE=cron # Available options: cron, interval
14+
# cron updates catalogs at specified times
15+
# interval updates in specific intervals
1416
CATALOG_UPDATE_CRON_SCHEDULES=[{"hour": 12, "minute": 0, "id": "catalog_refresh_noon"},{"hour": 0, "minute": 0, "id": "catalog_refresh_midnight"}]
1517
CATALOG_REFRESH_INTERVAL_SECONDS=6*60*60

app/api/endpoints/catalogs.py

Lines changed: 22 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,27 @@
33
from fastapi import APIRouter, HTTPException, Response
44
from loguru import logger
55

6-
from app.core.settings import decode_settings
6+
from app.core.security import redact_token
7+
from app.core.settings import UserSettings, get_default_settings
78
from app.services.catalog_updater import refresh_catalogs_for_credentials
89
from app.services.recommendation_service import RecommendationService
910
from app.services.stremio_service import StremioService
10-
from app.utils import redact_token, resolve_user_credentials
11+
from app.services.token_store import token_store
12+
13+
MAX_RESULTS = 50
14+
SOURCE_ITEMS_LIMIT = 10
1115

1216
router = APIRouter()
1317

1418

15-
@router.get("/catalog/{type}/{id}.json")
1619
@router.get("/{token}/catalog/{type}/{id}.json")
17-
@router.get("/{settings_str}/{token}/catalog/{type}/{id}.json")
18-
async def get_catalog(
19-
type: str,
20-
id: str,
21-
response: Response,
22-
token: str | None = None,
23-
settings_str: str | None = None,
24-
):
25-
"""
26-
Stremio catalog endpoint for movies and series.
27-
"""
20+
async def get_catalog(type: str, id: str, response: Response, token: str):
2821
if not token:
2922
raise HTTPException(
3023
status_code=400,
3124
detail="Missing credentials token. Please open Watchly from a configured manifest URL.",
3225
)
3326

34-
logger.info(f"[{redact_token(token)}] Fetching catalog for {type} with id {id}")
35-
36-
credentials = await resolve_user_credentials(token)
37-
3827
if type not in ["movie", "series"]:
3928
logger.warning(f"Invalid type: {type}")
4029
raise HTTPException(status_code=400, detail="Invalid type. Use 'movie' or 'series'")
@@ -51,22 +40,25 @@ async def get_catalog(
5140
" specific item IDs."
5241
),
5342
)
43+
44+
logger.info(f"[{redact_token(token)}] Fetching catalog for {type} with id {id}")
45+
46+
credentials = await token_store.get_user_data(token)
47+
if not credentials:
48+
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")
5449
try:
55-
# Decode settings to get language
56-
user_settings = decode_settings(settings_str) if settings_str else None
50+
# Extract settings from credentials
51+
settings_dict = credentials.get("settings", {})
52+
user_settings = UserSettings(**settings_dict) if settings_dict else get_default_settings()
5753
language = user_settings.language if user_settings else "en-US"
5854

5955
# Create services with credentials
60-
stremio_service = StremioService(
61-
username=credentials.get("username") or "",
62-
password=credentials.get("password") or "",
63-
auth_key=credentials.get("authKey"),
64-
)
56+
stremio_service = StremioService(auth_key=credentials.get("authKey"))
6557
recommendation_service = RecommendationService(
6658
stremio_service=stremio_service, language=language, user_settings=user_settings
6759
)
6860

69-
# Handle item-based recommendations (legacy or explicit link)
61+
# Handle item-based recommendations
7062
if id.startswith("tt"):
7163
recommendations = await recommendation_service.get_recommendations_for_item(item_id=id)
7264
logger.info(f"Found {len(recommendations)} recommendations for {id}")
@@ -84,12 +76,8 @@ async def get_catalog(
8476
logger.info(f"Found {len(recommendations)} recommendations for theme {id}")
8577

8678
else:
87-
# Top Picks (watchly.rec)
88-
8979
recommendations = await recommendation_service.get_recommendations(
90-
content_type=type,
91-
source_items_limit=10,
92-
max_results=50,
80+
content_type=type, source_items_limit=SOURCE_ITEMS_LIMIT, max_results=MAX_RESULTS
9381
)
9482
logger.info(f"Found {len(recommendations)} recommendations for {type}")
9583

@@ -101,7 +89,7 @@ async def get_catalog(
10189
except HTTPException:
10290
raise
10391
except Exception as e:
104-
logger.error(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}", exc_info=True)
92+
logger.exception(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}")
10593
raise HTTPException(status_code=500, detail=str(e))
10694

10795

@@ -111,9 +99,9 @@ async def update_catalogs(token: str):
11199
Update the catalogs for the addon. This is a manual endpoint to update the catalogs.
112100
"""
113101
# Decode credentials from path
114-
credentials = await resolve_user_credentials(token)
102+
credentials = await token_store.get_user_data(token)
115103

116104
logger.info(f"[{redact_token(token)}] Updating catalogs in response to manual request")
117-
updated = await refresh_catalogs_for_credentials(credentials)
105+
updated = await refresh_catalogs_for_credentials(token, credentials)
118106
logger.info(f"Manual catalog update completed: {updated}")
119107
return {"success": updated}

app/api/endpoints/manifest.py

Lines changed: 45 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from async_lru import alru_cache
2-
from fastapi import Response
2+
from fastapi import HTTPException, Response
33
from fastapi.routing import APIRouter
44

55
from app.core.config import settings
6-
from app.core.settings import UserSettings, decode_settings
6+
from app.core.settings import UserSettings, get_default_settings
77
from app.core.version import __version__
88
from app.services.catalog import DynamicCatalogService
99
from app.services.stremio_service import StremioService
10+
from app.services.token_store import token_store
1011
from app.services.translation import translation_service
11-
from app.utils import resolve_user_credentials
1212

1313
router = APIRouter()
1414

@@ -55,27 +55,19 @@ def get_base_manifest(user_settings: UserSettings | None = None):
5555
}
5656

5757

58-
# Cache catalog definitions for 1 hour (3600s)
5958
# Cache catalog definitions for 1 hour (3600s)
6059
@alru_cache(maxsize=1000, ttl=3600)
61-
async def fetch_catalogs(token: str | None = None, settings_str: str | None = None):
62-
if not token:
63-
return []
60+
async def fetch_catalogs(token: str):
61+
credentials = await token_store.get_user_data(token)
62+
if not credentials:
63+
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")
6464

65-
credentials = await resolve_user_credentials(token)
66-
67-
if settings_str:
68-
user_settings = decode_settings(settings_str)
69-
elif credentials.get("settings"):
65+
if credentials.get("settings"):
7066
user_settings = UserSettings(**credentials["settings"])
7167
else:
72-
user_settings = None
68+
user_settings = get_default_settings()
7369

74-
stremio_service = StremioService(
75-
username=credentials.get("username") or "",
76-
password=credentials.get("password") or "",
77-
auth_key=credentials.get("authKey"),
78-
)
70+
stremio_service = StremioService(auth_key=credentials.get("authKey"))
7971

8072
# Note: get_library_items is expensive, but we need it to determine *which* genre catalogs to show.
8173
library_items = await stremio_service.get_library_items()
@@ -88,75 +80,56 @@ async def fetch_catalogs(token: str | None = None, settings_str: str | None = No
8880
return catalogs
8981

9082

91-
async def _manifest_handler(response: Response, token: str | None, settings_str: str | None):
92-
"""Stremio manifest handler."""
93-
# Cache manifest for 1 day (86400 seconds)
83+
def get_config_id(catalog) -> str | None:
84+
catalog_id = catalog.get("id", "")
85+
if catalog_id.startswith("watchly.theme."):
86+
return "watchly.theme"
87+
if catalog_id.startswith("watchly.loved."):
88+
return "watchly.loved"
89+
if catalog_id.startswith("watchly.watched."):
90+
return "watchly.watched"
91+
if catalog_id.startswith("watchly.item."):
92+
return "watchly.item"
93+
if catalog_id.startswith("watchly.rec"):
94+
return "watchly.rec"
95+
return catalog_id
96+
97+
98+
async def _manifest_handler(response: Response, token: str):
9499
response.headers["Cache-Control"] = "public, max-age=86400"
95100

101+
if not token:
102+
raise HTTPException(status_code=401, detail="Missing token. Please reconfigure the addon.")
103+
96104
user_settings = None
97-
if settings_str:
98-
user_settings = decode_settings(settings_str)
99-
elif token:
100-
try:
101-
creds = await resolve_user_credentials(token)
102-
if creds.get("settings"):
103-
user_settings = UserSettings(**creds["settings"])
104-
except Exception:
105-
# Fallback to defaults if token resolution fails (or let it fail later in fetch_catalogs)
106-
pass
105+
try:
106+
creds = await token_store.get_user_data(token)
107+
if creds.get("settings"):
108+
user_settings = UserSettings(**creds["settings"])
109+
except Exception:
110+
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")
107111

108112
base_manifest = get_base_manifest(user_settings)
109113

114+
# translate to target language
110115
if user_settings and user_settings.language:
111116
for cat in base_manifest.get("catalogs", []):
112117
if cat.get("name"):
113118
cat["name"] = await translation_service.translate(cat["name"], user_settings.language)
114119

115-
if token:
116-
# We pass settings_str to fetch_catalogs so it can cache different versions
117-
# We COPY the lists to avoid modifying cached objects or base_manifest defaults
118-
fetched_catalogs = await fetch_catalogs(token, settings_str)
119-
120-
# Create a new list with copies of all catalogs
121-
all_catalogs = [c.copy() for c in base_manifest["catalogs"]] + [c.copy() for c in fetched_catalogs]
122-
123-
if user_settings:
124-
# Create a lookup for order index
125-
order_map = {c.id: i for i, c in enumerate(user_settings.catalogs)}
126-
127-
# Sort. Items not in map go to end.
128-
# Extract config id from catalog id for matching with user settings
129-
def get_config_id(catalog):
130-
catalog_id = catalog.get("id", "")
131-
if catalog_id.startswith("watchly.theme."):
132-
return "watchly.theme"
133-
if catalog_id.startswith("watchly.loved."):
134-
return "watchly.loved"
135-
if catalog_id.startswith("watchly.watched."):
136-
return "watchly.watched"
137-
if catalog_id.startswith("watchly.item."):
138-
return "watchly.item"
139-
if catalog_id.startswith("watchly.rec"):
140-
return "watchly.rec"
141-
return catalog_id
142-
143-
all_catalogs.sort(key=lambda x: order_map.get(get_config_id(x), 999))
144-
145-
base_manifest["catalogs"] = all_catalogs
120+
fetched_catalogs = await fetch_catalogs(token)
146121

147-
return base_manifest
122+
all_catalogs = [c.copy() for c in base_manifest["catalogs"]] + [c.copy() for c in fetched_catalogs]
123+
124+
if user_settings:
125+
order_map = {c.id: i for i, c in enumerate(user_settings.catalogs)}
126+
all_catalogs.sort(key=lambda x: order_map.get(get_config_id(x), 999))
148127

128+
base_manifest["catalogs"] = all_catalogs
149129

150-
@router.get("/manifest.json")
151-
async def manifest_root(response: Response):
152-
return await _manifest_handler(response, None, None)
130+
return base_manifest
153131

154132

155133
@router.get("/{token}/manifest.json")
156134
async def manifest_token(response: Response, token: str):
157-
return await _manifest_handler(response, token, None)
158-
159-
160-
@router.get("/{settings_str}/{token}/manifest.json")
161-
async def manifest_settings(response: Response, settings_str: str, token: str):
162-
return await _manifest_handler(response, token, settings_str)
135+
return await _manifest_handler(response, token)

0 commit comments

Comments
 (0)