Skip to content

Commit 2654f9d

Browse files
feat: enhance catalog and recommendation services with Redis caching (#88)
* feat: enhance catalog and recommendation services with Redis caching * feat: enhance dynamic catalog building with Redis caching for library items and profiles * feat: refactor manifest handling and introduce ManifestService for improved structure and caching * feat: refactor profile caching logic to streamline profile and watched sets retrieval * feat: refactor catalog updater to utilize ManifestService for library and profile caching * feat: implement UserCacheService for centralized caching of library items, profiles, and watched sets * feat: enhance catalog caching by integrating UserCacheService for improved retrieval and storage * feat: enhance catalog caching with configurable TTL and improved token logging * feat: streamline catalog retrieval by removing redundant caching logic and enhancing cache invalidation for user data updates * feat: improve token logging in UserCacheService by redacting sensitive information in debug and warning messages * refactor: update cache_profile_and_watched_sets to return profile and watched sets, simplifying retrieval in ManifestService and CatalogService
1 parent 62b5b6d commit 2654f9d

File tree

14 files changed

+740
-219
lines changed

14 files changed

+740
-219
lines changed

app/api/endpoints/catalogs.py

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,12 @@
1-
import json
2-
31
from fastapi import APIRouter, HTTPException, Response
42
from loguru import logger
53

6-
from app.core.config import settings
74
from app.core.security import redact_token
85
from app.services.recommendation.catalog_service import catalog_service
9-
from app.services.redis_service import redis_service
106

117
router = APIRouter()
128

139

14-
def _clean_meta(meta: dict) -> dict:
15-
"""Return a sanitized Stremio meta object without internal fields.
16-
17-
Keeps only public keys and drops internal scoring/IDs/keywords/cast, etc.
18-
"""
19-
allowed = {
20-
"id",
21-
"type",
22-
"name",
23-
"poster",
24-
"background",
25-
"description",
26-
"releaseInfo",
27-
"imdbRating",
28-
"genres",
29-
"runtime",
30-
}
31-
cleaned = {k: v for k, v in meta.items() if k in allowed}
32-
# Drop empty values
33-
cleaned = {k: v for k, v in cleaned.items() if v not in (None, "", [], {}, ())}
34-
35-
# if id does not start with tt, return None
36-
if not cleaned.get("id", "").startswith("tt"):
37-
return None
38-
return cleaned
39-
40-
4110
@router.get("/{token}/catalog/{type}/{id}.json")
4211
async def get_catalog(type: str, id: str, response: Response, token: str):
4312
"""
@@ -46,28 +15,14 @@ async def get_catalog(type: str, id: str, response: Response, token: str):
4615
This endpoint delegates all logic to CatalogService facade.
4716
"""
4817
try:
49-
# catalog_key
50-
catalog_key = f"watchly:catalog:{token}:{type}:{id}"
51-
cached_data = await redis_service.get(catalog_key)
52-
if cached_data:
53-
return json.loads(cached_data)
54-
5518
# Delegate to catalog service facade
5619
recommendations, headers = await catalog_service.get_catalog(token, type, id)
5720

5821
# Set response headers
5922
for key, value in headers.items():
6023
response.headers[key] = value
6124

62-
# Clean and format metadata
63-
cleaned = [_clean_meta(m) for m in recommendations]
64-
cleaned = [m for m in cleaned if m is not None]
65-
66-
data = {"metas": cleaned}
67-
# if catalog data is not empty, set the cache
68-
if cleaned:
69-
await redis_service.set(catalog_key, json.dumps(data), settings.CATALOG_CACHE_TTL)
70-
return data
25+
return recommendations
7126

7227
except HTTPException:
7328
raise

app/api/endpoints/manifest.py

Lines changed: 5 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,20 @@
1-
from fastapi import HTTPException
21
from fastapi.routing import APIRouter
3-
from loguru import logger
42

5-
from app.core.config import settings
6-
from app.core.settings import UserSettings
7-
from app.core.version import __version__
8-
from app.services.catalog import DynamicCatalogService
9-
from app.services.catalog_updater import get_config_id
10-
from app.services.stremio.service import StremioBundle
11-
from app.services.token_store import token_store
12-
from app.services.translation import translation_service
3+
from app.services.manifest import manifest_service
134

145
router = APIRouter()
156

167

17-
def get_base_manifest():
18-
return {
19-
"id": settings.ADDON_ID,
20-
"version": __version__,
21-
"name": settings.ADDON_NAME,
22-
"description": "Movie and series recommendations based on your Stremio library.",
23-
"logo": "https://raw.githubusercontent.com/TimilsinaBimal/Watchly/refs/heads/main/app/static/logo.png",
24-
"background": ("https://raw.githubusercontent.com/TimilsinaBimal/Watchly/refs/heads/main/app/static/cover.png"),
25-
"resources": ["catalog"],
26-
"types": ["movie", "series"],
27-
"idPrefixes": ["tt"],
28-
"catalogs": [],
29-
"behaviorHints": {"configurable": True, "configurationRequired": False},
30-
"stremioAddonsConfig": {
31-
"issuer": "https://stremio-addons.net",
32-
"signature": (
33-
"eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0..WSrhzzlj1TuDycD6QoVLuA.Dzmxzr4y83uqQF15r4tC1bB9-vtZRh1Rvy4BqgDYxu91c2esiJuov9KnnI_cboQCgZS7hjwnIqRSlQ-jEyGwXHHRerh9QklyfdxpXqNUyBgTWFzDOVdVvDYJeM_tGMmR.sezAChlWGV7lNS-t9HWB6A" # noqa
34-
),
35-
},
36-
}
37-
38-
39-
async def build_dynamic_catalogs(
40-
bundle: StremioBundle, auth_key: str, user_settings: UserSettings | None
41-
) -> list[dict]:
42-
# Fetch library using bundle directly
43-
if not user_settings:
44-
logger.error("User settings not found. Please reconfigure the addon.")
45-
raise HTTPException(status_code=401, detail="User settings not found. Please reconfigure the addon.")
46-
47-
library_items = await bundle.library.get_library_items(auth_key)
48-
dynamic_catalog_service = DynamicCatalogService(
49-
language=user_settings.language,
50-
)
51-
return await dynamic_catalog_service.get_dynamic_catalogs(library_items, user_settings)
52-
53-
54-
async def _manifest_handler(token: str):
55-
# response.headers["Cache-Control"] = "public, max-age=300" # 5 minutes
56-
if not token:
57-
raise HTTPException(status_code=401, detail="Missing token. Please reconfigure the addon.")
58-
59-
user_settings = None
60-
try:
61-
creds = await token_store.get_user_data(token)
62-
if creds and creds.get("settings"):
63-
user_settings = UserSettings(**creds["settings"])
64-
except Exception as e:
65-
logger.error(f"[{token}] Error loading user data from token store: {e}")
66-
raise HTTPException(status_code=401, detail="Invalid token session. Please reconfigure.")
67-
68-
if not creds:
69-
raise HTTPException(status_code=401, detail="Token not found. Please reconfigure the addon.")
70-
71-
base_manifest = get_base_manifest()
72-
73-
bundle = StremioBundle()
74-
fetched_catalogs = []
75-
try:
76-
# Resolve Auth Key (with potential fallback to login)
77-
auth_key = creds.get("authKey")
78-
email = creds.get("email")
79-
password = creds.get("password")
80-
81-
is_valid = False
82-
if auth_key:
83-
try:
84-
await bundle.auth.get_user_info(auth_key)
85-
is_valid = True
86-
except Exception as e:
87-
logger.debug(f"Auth key check failed for {email or 'unknown'}: {e}")
88-
pass
89-
90-
if not is_valid and email and password:
91-
try:
92-
auth_key = await bundle.auth.login(email, password)
93-
# Update store
94-
creds["authKey"] = auth_key
95-
await token_store.update_user_data(token, creds)
96-
except Exception as e:
97-
logger.error(f"Failed to refresh auth key during manifest fetch: {e}")
98-
99-
if auth_key:
100-
fetched_catalogs = await build_dynamic_catalogs(
101-
bundle,
102-
auth_key,
103-
user_settings,
104-
)
105-
except Exception as e:
106-
logger.exception(f"[{token}] Dynamic catalog build failed: {e}")
107-
fetched_catalogs = []
108-
finally:
109-
await bundle.close()
110-
111-
all_catalogs = [c.copy() for c in base_manifest["catalogs"]] + [c.copy() for c in fetched_catalogs]
112-
113-
translated_catalogs = []
114-
115-
# translate to target language
116-
if user_settings and user_settings.language:
117-
for cat in all_catalogs:
118-
if cat.get("name"):
119-
try:
120-
cat["name"] = await translation_service.translate(cat["name"], user_settings.language)
121-
except Exception as e:
122-
logger.warning(f"Failed to translate catalog name '{cat.get('name')}': {e}")
123-
translated_catalogs.append(cat)
124-
else:
125-
translated_catalogs = all_catalogs
126-
127-
if user_settings:
128-
order_map = {c.id: i for i, c in enumerate(user_settings.catalogs)}
129-
translated_catalogs.sort(key=lambda x: order_map.get(get_config_id(x), 999))
130-
131-
if translated_catalogs:
132-
base_manifest["catalogs"] = translated_catalogs
133-
134-
return base_manifest
135-
136-
1378
@router.get("/manifest.json")
1389
async def manifest():
139-
manifest = get_base_manifest()
10+
"""Get base manifest for unauthenticated users."""
11+
manifest = manifest_service.get_base_manifest()
14012
# since user is not logged in, return empty catalogs
14113
manifest["catalogs"] = []
14214
return manifest
14315

14416

14517
@router.get("/{token}/manifest.json")
14618
async def manifest_token(token: str):
147-
return await _manifest_handler(token)
19+
"""Get manifest for authenticated user."""
20+
return await manifest_service.get_manifest_for_token(token)

app/api/endpoints/tokens.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from app.core.config import settings
88
from app.core.security import redact_token
99
from app.core.settings import CatalogConfig, UserSettings, get_default_settings
10+
from app.services.manifest import manifest_service
1011
from app.services.stremio.service import StremioBundle
1112
from app.services.token_store import token_store
1213

@@ -101,11 +102,25 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
101102

102103
# 5. Store user data
103104
token = await token_store.store_user_data(user_id, payload_to_store)
104-
logger.info(f"[{redact_token(token)}] Account {'updated' if existing_data else 'created'} for user {user_id}")
105+
account_status = "updated" if existing_data else "created"
106+
logger.info(f"[{redact_token(token)}] Account {account_status} for user {user_id}")
107+
108+
# 6. Cache library items and profiles before returning
109+
# This ensures manifest generation is fast when user installs the addon
110+
# We wait for caching to complete so everything is ready immediately
111+
try:
112+
logger.info(f"[{redact_token(token)}] Caching library and profiles before returning token")
113+
await manifest_service.cache_library_and_profiles(bundle, stremio_auth_key, user_settings, token)
114+
logger.info(f"[{redact_token(token)}] Successfully cached library and profiles")
115+
except Exception as e:
116+
logger.warning(
117+
f"[{redact_token(token)}] Failed to cache library and profiles: {e}. "
118+
"Continuing anyway - will cache on manifest request."
119+
)
120+
# Continue even if caching fails - manifest service will handle it
105121

106122
base_url = settings.HOST_NAME
107123
manifest_url = f"{base_url}/{token}/manifest.json"
108-
# Maybe generate manifest and check if catalogs exist and if not raise error?
109124
expires_in = settings.TOKEN_TTL_SECONDS if settings.TOKEN_TTL_SECONDS > 0 else None
110125

111126
await bundle.close()

app/core/app.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from app.api.endpoints.meta import fetch_languages_list
1313
from app.api.main import api_router
14+
from app.services.redis_service import redis_service
1415
from app.services.token_store import token_store
1516

1617
from .config import settings
@@ -28,10 +29,10 @@ async def lifespan(app: FastAPI):
2829
"""
2930
yield
3031
try:
31-
await token_store.close()
32-
logger.info("TokenStore Redis client closed")
32+
await redis_service.close()
33+
logger.info("Redis client closed")
3334
except Exception as exc:
34-
logger.warning(f"Failed to close TokenStore Redis client: {exc}")
35+
logger.warning(f"Failed to close Redis client: {exc}")
3536

3637

3738
app = FastAPI(

app/core/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class Settings(BaseSettings):
3838
RECOMMENDATION_SOURCE_ITEMS_LIMIT: int = 10
3939
LIBRARY_ITEMS_LIMIT: int = 20
4040

41-
CATALOG_CACHE_TTL: int = 12 * 60 * 60 # 12 hours
41+
CATALOG_CACHE_TTL: int = 43200 # 12 hours
4242

4343
# AI
4444
DEFAULT_GEMINI_MODEL: str = "gemma-3-27b-it"

app/core/constants.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
RECOMMENDATIONS_CATALOG_NAME: str = "Top Picks For You"
22
DEFAULT_MIN_ITEMS: int = 8
3-
DEFAULT_CATALOG_LIMIT = 20
3+
DEFAULT_CATALOG_LIMIT: int = 20
44

55
DEFAULT_CONCURRENCY_LIMIT: int = 30
66

7-
87
DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_MOVIE: float = 7.2
98
DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_TV: float = 6.8
9+
10+
11+
# cache keys
12+
LIBRARY_ITEMS_KEY: str = "watchly:library_items:{token}"
13+
PROFILE_KEY: str = "watchly:profile:{token}:{content_type}"
14+
WATCHED_SETS_KEY: str = "watchly:watched_sets:{token}:{content_type}"
15+
CATALOG_KEY: str = "watchly:catalog:{token}:{type}:{id}"

app/services/catalog_updater.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,11 @@
99
from app.core.security import redact_token
1010
from app.core.settings import UserSettings
1111
from app.services.catalog import DynamicCatalogService
12+
from app.services.manifest import manifest_service
1213
from app.services.stremio.service import StremioBundle
1314
from app.services.token_store import token_store
1415
from app.services.translation import translation_service
15-
16-
17-
def get_config_id(catalog) -> str | None:
18-
catalog_id = catalog.get("id", "")
19-
if catalog_id.startswith("watchly.theme."):
20-
return "watchly.theme"
21-
if catalog_id.startswith("watchly.loved."):
22-
return "watchly.loved"
23-
if catalog_id.startswith("watchly.watched."):
24-
return "watchly.watched"
25-
return catalog_id
16+
from app.utils.catalog import get_config_id
2617

2718

2819
class CatalogUpdater:
@@ -116,13 +107,15 @@ async def refresh_catalogs_for_credentials(
116107
user_settings = UserSettings(**credentials["settings"])
117108
except Exception as e:
118109
logger.exception(f"[{redact_token(token)}] Failed to parse user settings: {e}")
119-
return True # if user doesn't have setting, we can't update the catalogs. so no need to try again.
110+
# if user doesn't have setting, we can't update the catalogs.
111+
# so no need to try again.
112+
return True
120113

121-
# Fetch fresh library
122-
library_items = await bundle.library.get_library_items(auth_key)
114+
library_items = await manifest_service.cache_library_and_profiles(bundle, auth_key, user_settings, token)
115+
language = user_settings.language if user_settings else "en-US"
123116

124117
dynamic_catalog_service = DynamicCatalogService(
125-
language=(user_settings.language if user_settings else "en-US"),
118+
language=language,
126119
)
127120

128121
catalogs = await dynamic_catalog_service.get_dynamic_catalogs(

0 commit comments

Comments
 (0)