Skip to content

Commit d3cb3ac

Browse files
feat: add default item limits and concurrency settings for recommendations
1 parent b1ebaa7 commit d3cb3ac

File tree

9 files changed

+58
-97
lines changed

9 files changed

+58
-97
lines changed

app/core/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
RECOMMENDATIONS_CATALOG_NAME: str = "Top Picks For You"
2+
DEFAULT_MIN_ITEMS: int = 20
3+
DEFAULT_MAX_ITEMS: int = 32
4+
5+
DEFAULT_CONCURRENCY_LIMIT: int = 30

app/services/recommendation/catalog_service.py

Lines changed: 33 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
"""
2-
Catalog Service - Facade for catalog generation.
3-
4-
Encapsulates all catalog logic: auth, profile building, routing, and recommendations.
5-
"""
6-
71
import re
82
from typing import Any
93

4+
from fastapi import HTTPException
105
from loguru import logger
116

127
from app.api.endpoints.manifest import get_config_id
138
from app.core.config import settings
9+
from app.core.constants import DEFAULT_MAX_ITEMS, DEFAULT_MIN_ITEMS
1410
from app.core.settings import UserSettings, get_default_settings
1511
from app.models.taste_profile import TasteProfile
1612
from app.services.catalog_updater import catalog_updater
@@ -24,19 +20,9 @@
2420
from app.services.tmdb.service import get_tmdb_service
2521
from app.services.token_store import token_store
2622

27-
DEFAULT_MIN_ITEMS = 20
28-
DEFAULT_MAX_ITEMS = 32
29-
3023

3124
class CatalogService:
32-
"""
33-
Facade for catalog generation.
34-
35-
Handles all catalog logic: validation, auth, profile building, routing, and recommendations.
36-
"""
37-
3825
def __init__(self):
39-
"""Initialize catalog service."""
4026
pass
4127

4228
async def get_catalog(
@@ -61,13 +47,18 @@ async def get_catalog(
6147
# Get credentials
6248
credentials = await token_store.get_user_data(token)
6349
if not credentials:
64-
from fastapi import HTTPException
65-
50+
logger.error("No credentials found for token")
6651
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")
6752

6853
# Trigger lazy update if needed
6954
if settings.AUTO_UPDATE_CATALOGS:
70-
await catalog_updater.trigger_update(token, credentials)
55+
logger.info(f"[{token[:8]}...] Triggering auto update for token")
56+
try:
57+
await catalog_updater.trigger_update(token, credentials)
58+
except Exception as e:
59+
logger.error(f"[{token[:8]}...] Failed to trigger auto update: {e}")
60+
# continue with the request even if the auto update fails
61+
pass
7162

7263
bundle = StremioBundle()
7364
try:
@@ -82,11 +73,13 @@ async def get_catalog(
8273
# Initialize services
8374
services = self._initialize_services(language, user_settings)
8475

76+
integration_service: ProfileIntegration = services["integration"]
77+
8578
# Build profile and watched sets (once, reused)
86-
profile, watched_tmdb, watched_imdb = await services["integration"].build_profile_from_library(
79+
profile, watched_tmdb, watched_imdb = await integration_service.build_profile_from_library(
8780
library_items, content_type, bundle, auth_key
8881
)
89-
whitelist = await services["integration"].get_genre_whitelist(profile, content_type) if profile else set()
82+
whitelist = await integration_service.get_genre_whitelist(profile, content_type) if profile else set()
9083

9184
# Get catalog limits
9285
min_items, max_items = self._get_catalog_limits(catalog_id, user_settings)
@@ -127,9 +120,6 @@ async def get_catalog(
127120
await bundle.close()
128121

129122
def _validate_inputs(self, token: str, content_type: str, catalog_id: str) -> None:
130-
"""Validate input parameters."""
131-
from fastapi import HTTPException
132-
133123
if not token:
134124
raise HTTPException(
135125
status_code=400,
@@ -144,26 +134,18 @@ def _validate_inputs(self, token: str, content_type: str, catalog_id: str) -> No
144134
if catalog_id not in ["watchly.rec", "watchly.creators"] and not any(
145135
catalog_id.startswith(p)
146136
for p in (
147-
"tt",
148137
"watchly.theme.",
149-
"watchly.item.",
150138
"watchly.loved.",
151139
"watchly.watched.",
152140
)
153141
):
154142
logger.warning(f"Invalid id: {catalog_id}")
155143
raise HTTPException(
156144
status_code=400,
157-
detail=(
158-
"Invalid id. Supported: 'watchly.rec', 'watchly.creators', 'watchly.theme.<params>',"
159-
"'watchly.item.<id>', or specific item IDs."
160-
),
145+
detail=("Invalid id. Supported: 'watchly.rec', 'watchly.creators', 'watchly.theme.<params>'"),
161146
)
162147

163148
async def _resolve_auth(self, bundle: StremioBundle, credentials: dict, token: str) -> str:
164-
"""Resolve and validate auth key."""
165-
from fastapi import HTTPException
166-
167149
auth_key = credentials.get("authKey")
168150
email = credentials.get("email")
169151
password = credentials.get("password")
@@ -174,7 +156,8 @@ async def _resolve_auth(self, bundle: StremioBundle, credentials: dict, token: s
174156
try:
175157
await bundle.auth.get_user_info(auth_key)
176158
is_valid = True
177-
except Exception:
159+
except Exception as e:
160+
logger.error(f"Failed to validate auth key during catalog fetch: {e}")
178161
pass
179162

180163
# Try to refresh if invalid
@@ -188,31 +171,27 @@ async def _resolve_auth(self, bundle: StremioBundle, credentials: dict, token: s
188171
logger.error(f"Failed to refresh auth key during catalog fetch: {e}")
189172

190173
if not auth_key:
174+
logger.error("No auth key found during catalog fetch")
191175
raise HTTPException(status_code=401, detail="Stremio session expired. Please reconfigure.")
192176

193177
return auth_key
194178

195179
def _extract_settings(self, credentials: dict) -> UserSettings:
196-
"""Extract user settings from credentials."""
197180
settings_dict = credentials.get("settings", {})
198181
return UserSettings(**settings_dict) if settings_dict else get_default_settings()
199182

200183
def _initialize_services(self, language: str, user_settings: UserSettings) -> dict[str, Any]:
201-
"""Initialize all recommendation services."""
202184
tmdb_service = get_tmdb_service(language=language)
203-
integration = ProfileIntegration(language=language)
204-
205185
return {
206186
"tmdb": tmdb_service,
207-
"integration": integration,
187+
"integration": ProfileIntegration(language=language),
208188
"item": ItemBasedService(tmdb_service, user_settings),
209189
"theme": ThemeBasedService(tmdb_service, user_settings),
210190
"top_picks": TopPicksService(tmdb_service, user_settings),
211191
"creators": CreatorsService(tmdb_service, user_settings),
212192
}
213193

214194
def _get_catalog_limits(self, catalog_id: str, user_settings: UserSettings) -> tuple[int, int]:
215-
"""Get min/max items for catalog."""
216195
try:
217196
cfg_id = get_config_id({"id": catalog_id})
218197
except Exception:
@@ -255,21 +234,19 @@ async def _get_recommendations(
255234
) -> list[dict[str, Any]]:
256235
"""Route to appropriate recommendation service based on catalog ID."""
257236
# Item-based recommendations
258-
if catalog_id.startswith("tt") or any(
237+
if any(
259238
catalog_id.startswith(p)
260239
for p in (
261-
"watchly.item.",
262240
"watchly.loved.",
263241
"watchly.watched.",
264242
)
265243
):
266244
# Extract item ID
267-
if catalog_id.startswith("tt"):
268-
item_id = catalog_id
269-
else:
270-
item_id = re.sub(r"^watchly\.(item|loved|watched)\.", "", catalog_id)
245+
item_id = re.sub(r"^watchly\.(loved|watched)\.", "", catalog_id)
271246

272-
recommendations = await services["item"].get_recommendations_for_item(
247+
item_service: ItemBasedService = services["item"]
248+
249+
recommendations = await item_service.get_recommendations_for_item(
273250
item_id=item_id,
274251
content_type=content_type,
275252
watched_tmdb=watched_tmdb,
@@ -281,7 +258,9 @@ async def _get_recommendations(
281258

282259
# Theme-based recommendations
283260
elif catalog_id.startswith("watchly.theme."):
284-
recommendations = await services["theme"].get_recommendations_for_theme(
261+
theme_service: ThemeBasedService = services["theme"]
262+
263+
recommendations = await theme_service.get_recommendations_for_theme(
285264
theme_id=catalog_id,
286265
content_type=content_type,
287266
profile=profile,
@@ -294,8 +273,10 @@ async def _get_recommendations(
294273

295274
# Creators-based recommendations
296275
elif catalog_id == "watchly.creators":
276+
creators_service: CreatorsService = services["creators"]
277+
297278
if profile:
298-
recommendations = await services["creators"].get_recommendations_from_creators(
279+
recommendations = await creators_service.get_recommendations_from_creators(
299280
profile=profile,
300281
content_type=content_type,
301282
library_items=library_items,
@@ -311,7 +292,9 @@ async def _get_recommendations(
311292
# Top picks
312293
elif catalog_id == "watchly.rec":
313294
if profile:
314-
recommendations = await services["top_picks"].get_top_picks(
295+
top_picks_service: TopPicksService = services["top_picks"]
296+
297+
recommendations = await top_picks_service.get_top_picks(
315298
profile=profile,
316299
content_type=content_type,
317300
library_items=library_items,

app/services/recommendation/creators.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,6 @@ async def get_recommendations_from_creators(
157157

158158
# Genre whitelist check
159159
genre_ids = item.get("genre_ids", [])
160-
if not RecommendationFiltering.passes_top_genre_whitelist(genre_ids, whitelist):
161-
continue
162160

163161
# Excluded genres check
164162
if excluded_ids and any(gid in excluded_ids for gid in genre_ids):
@@ -168,7 +166,7 @@ async def get_recommendations_from_creators(
168166

169167
# Enrich metadata
170168
enriched = await RecommendationMetadata.fetch_batch(
171-
self.tmdb_service, filtered, content_type, target_count=limit, user_settings=self.user_settings
169+
self.tmdb_service, filtered, content_type, user_settings=self.user_settings
172170
)
173171

174172
# Final filter (remove watched by IMDB ID)

app/services/recommendation/filtering.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,4 @@ def passes_top_genre_whitelist(genre_ids: list[int] | None, whitelist: set[int])
162162
gids = set(genre_ids or [])
163163
if not gids:
164164
return True
165-
# If it's animation and not in whitelist, we still block it to prevent 'Anime Takeover'
166-
if 16 in gids and 16 not in whitelist:
167-
return False
168165
return True

app/services/recommendation/item_based.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,13 @@ async def get_recommendations_for_item(
7171

7272
# Enrich metadata
7373
enriched = await RecommendationMetadata.fetch_batch(
74-
self.tmdb_service, filtered, content_type, target_count=limit, user_settings=self.user_settings
74+
self.tmdb_service, filtered, content_type, user_settings=self.user_settings
7575
)
7676

7777
# Final filter (remove watched by IMDB ID)
7878
final = filter_watched_by_imdb(enriched, watched_imdb or set())
7979

80-
return final[:limit]
80+
return final
8181

8282
async def _fetch_candidates(self, tmdb_id: int, mtype: str) -> list[dict[str, Any]]:
8383
"""

app/services/recommendation/metadata.py

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import asyncio
22
from typing import Any
33

4+
from loguru import logger
5+
6+
from app.core.constants import DEFAULT_CONCURRENCY_LIMIT
47
from app.services.rpdb import RPDBService
58

69

@@ -117,14 +120,13 @@ async def fetch_batch(
117120
tmdb_service: Any,
118121
items: list[dict[str, Any]],
119122
media_type: str,
120-
target_count: int,
121123
user_settings: Any = None,
122124
) -> list[dict[str, Any]]:
123125
"""Fetch details for a batch of items in parallel with target-based short-circuiting."""
124126
final_results = []
125127
valid_items = [it for it in items if it.get("id")]
126128
query_type = "movie" if media_type == "movie" else "tv"
127-
sem = asyncio.Semaphore(30)
129+
sem = asyncio.Semaphore(DEFAULT_CONCURRENCY_LIMIT)
128130

129131
async def _fetch_one(tid: int):
130132
async with sem:
@@ -135,25 +137,20 @@ async def _fetch_one(tid: int):
135137
except Exception:
136138
return None
137139

138-
# Process in chunks to allow early exit once target_count is reached
139-
batch_size = 20
140-
for i in range(0, len(valid_items), batch_size):
141-
if len(final_results) >= target_count:
142-
break
143-
144-
chunk = valid_items[i : i + batch_size] # noqa
145-
tasks = [_fetch_one(it["id"]) for it in chunk]
146-
details_list = await asyncio.gather(*tasks)
140+
tasks = [_fetch_one(it.get("id")) for it in valid_items]
141+
details_list = await asyncio.gather(*tasks)
147142

148-
for details in details_list:
149-
if not details:
150-
continue
143+
format_task = [
144+
cls.format_for_stremio(details, media_type, user_settings) for details in details_list if details
145+
]
151146

152-
formatted = await cls.format_for_stremio(details, media_type, user_settings)
153-
if formatted:
154-
final_results.append(formatted)
147+
formatted_list = await asyncio.gather(*format_task, return_exceptions=True)
155148

156-
if len(final_results) >= target_count:
157-
break
149+
for formatted in formatted_list:
150+
if isinstance(formatted, Exception):
151+
logger.warning(f"Error formatting metadata: {formatted}")
152+
continue
153+
if formatted:
154+
final_results.append(formatted)
158155

159156
return final_results

app/services/recommendation/theme_based.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ async def get_recommendations_for_theme(
145145

146146
# Enrich metadata
147147
enriched = await RecommendationMetadata.fetch_batch(
148-
self.tmdb_service, filtered, content_type, target_count=limit, user_settings=self.user_settings
148+
self.tmdb_service, filtered, content_type, user_settings=self.user_settings
149149
)
150150

151151
# Final filter (remove watched by IMDB ID)

app/services/recommendation/top_picks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ async def get_top_picks(
135135

136136
# 8. Enrich metadata
137137
enriched = await RecommendationMetadata.fetch_batch(
138-
self.tmdb_service, result, content_type, target_count=limit, user_settings=self.user_settings
138+
self.tmdb_service, result, content_type, user_settings=self.user_settings
139139
)
140140

141141
# 9. Apply creator cap (after enrichment, we have full metadata)

app/services/recommendation/utils.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
"""
2-
Utility functions for recommendations.
3-
"""
4-
51
from typing import Any
62

73
from loguru import logger
@@ -11,15 +7,6 @@
117

128

139
def content_type_to_mtype(content_type: str) -> str:
14-
"""
15-
Convert content_type to TMDB media type.
16-
17-
Args:
18-
content_type: Content type (movie/series/tv)
19-
20-
Returns:
21-
TMDB media type (movie/tv)
22-
"""
2310
return "tv" if content_type in ("tv", "series") else "movie"
2411

2512

@@ -103,10 +90,6 @@ def filter_by_genres(
10390

10491
genre_ids = item.get("genre_ids", [])
10592

106-
# Genre whitelist check
107-
if not RecommendationFiltering.passes_top_genre_whitelist(genre_ids, whitelist):
108-
continue
109-
11093
# Excluded genres check
11194
if excluded_ids and any(gid in excluded_ids for gid in genre_ids):
11295
continue
@@ -198,7 +181,6 @@ async def pad_to_min(
198181
tmdb_service,
199182
list(dedup.values()),
200183
content_type,
201-
target_count=need * 2,
202184
user_settings=user_settings,
203185
)
204186

0 commit comments

Comments
 (0)