Skip to content

Commit 9731be4

Browse files
refactor: streamline catalog fetching by integrating CatalogService and simplifying endpoint logic
1 parent ca46319 commit 9731be4

File tree

7 files changed

+381
-370
lines changed

7 files changed

+381
-370
lines changed

app/api/endpoints/catalogs.py

Lines changed: 15 additions & 252 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,14 @@
1-
import re
2-
31
from fastapi import APIRouter, HTTPException, Response
42
from loguru import logger
53

6-
from app.api.endpoints.manifest import get_config_id
7-
from app.core.config import settings
84
from app.core.security import redact_token
9-
from app.core.settings import UserSettings, get_default_settings
10-
from app.services.catalog_updater import catalog_updater
11-
from app.services.profile.integration import ProfileIntegration
12-
from app.services.recommendation.creators import CreatorsService
13-
from app.services.recommendation.item_based import ItemBasedService
14-
from app.services.recommendation.theme_based import ThemeBasedService
15-
from app.services.recommendation.top_picks import TopPicksService
16-
from app.services.recommendation.utils import pad_to_min
17-
from app.services.stremio.service import StremioBundle
18-
from app.services.tmdb.service import get_tmdb_service
19-
from app.services.token_store import token_store
20-
21-
MAX_RESULTS = 50
22-
DEFAULT_MIN_ITEMS = 20
23-
DEFAULT_MAX_ITEMS = 32
5+
from app.services.recommendation.catalog_service import CatalogService
246

257
router = APIRouter()
268

9+
# Initialize catalog service (singleton)
10+
_catalog_service = CatalogService()
11+
2712

2813
def _clean_meta(meta: dict) -> dict:
2914
"""Return a sanitized Stremio meta object without internal fields.
@@ -54,249 +39,27 @@ def _clean_meta(meta: dict) -> dict:
5439

5540
@router.get("/{token}/catalog/{type}/{id}.json")
5641
async def get_catalog(type: str, id: str, response: Response, token: str):
57-
if not token:
58-
raise HTTPException(
59-
status_code=400,
60-
detail="Missing credentials token. Please open Watchly from a configured manifest URL.",
61-
)
62-
63-
if type not in ["movie", "series"]:
64-
logger.warning(f"Invalid type: {type}")
65-
raise HTTPException(status_code=400, detail="Invalid type. Use 'movie' or 'series'")
66-
67-
# Supported IDs now include dynamic themes and item-based rows
68-
if id not in ["watchly.rec", "watchly.creators"] and not any(
69-
id.startswith(p)
70-
for p in (
71-
"tt",
72-
"watchly.theme.",
73-
"watchly.item.",
74-
"watchly.loved.",
75-
"watchly.watched.",
76-
)
77-
):
78-
logger.warning(f"Invalid id: {id}")
79-
raise HTTPException(
80-
status_code=400,
81-
detail=( #
82-
"Invalid id. Supported: 'watchly.rec', 'watchly.creators', 'watchly.theme.<params>',"
83-
"'watchly.item.<id>', or specific item IDs."
84-
),
85-
)
86-
87-
logger.info(f"[{redact_token(token)}] Fetching catalog for {type} with id {id}")
88-
89-
credentials = await token_store.get_user_data(token)
90-
if not credentials:
91-
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")
92-
93-
# Trigger lazy update if needed
94-
if settings.AUTO_UPDATE_CATALOGS:
95-
await catalog_updater.trigger_update(token, credentials)
42+
"""
43+
Get catalog recommendations.
9644
97-
bundle = StremioBundle()
45+
This endpoint delegates all logic to CatalogService facade.
46+
"""
9847
try:
99-
# 1. Resolve Auth Key (with potential fallback to login)
100-
auth_key = credentials.get("authKey")
101-
email = credentials.get("email")
102-
password = credentials.get("password")
103-
104-
is_valid = False
105-
if auth_key:
106-
try:
107-
await bundle.auth.get_user_info(auth_key)
108-
is_valid = True
109-
except Exception:
110-
pass
111-
112-
if not is_valid and email and password:
113-
try:
114-
auth_key = await bundle.auth.login(email, password)
115-
credentials["authKey"] = auth_key
116-
await token_store.update_user_data(token, credentials)
117-
except Exception as e:
118-
logger.error(f"Failed to refresh auth key during catalog fetch: {e}")
119-
120-
if not auth_key:
121-
raise HTTPException(status_code=401, detail="Stremio session expired. Please reconfigure.")
122-
123-
# 2. Extract settings from credentials
124-
settings_dict = credentials.get("settings", {})
125-
user_settings = UserSettings(**settings_dict) if settings_dict else get_default_settings()
126-
language = user_settings.language if user_settings else "en-US"
127-
128-
# 3. Fetch library once per request and reuse across recommendation paths
129-
library_items = await bundle.library.get_library_items(auth_key)
130-
131-
# Initialize services
132-
tmdb_service = get_tmdb_service(language=language)
133-
integration = ProfileIntegration(language=language)
134-
item_service = ItemBasedService(tmdb_service, user_settings)
135-
theme_service = ThemeBasedService(tmdb_service, user_settings)
136-
top_picks_service = TopPicksService(tmdb_service, user_settings)
137-
creators_service = CreatorsService(tmdb_service, user_settings)
138-
139-
# Resolve per-catalog limits (min/max)
140-
def _get_limits() -> tuple[int, int]:
141-
try:
142-
cfg_id = get_config_id({"id": id})
143-
except Exception:
144-
cfg_id = id
145-
try:
146-
cfg = next((c for c in user_settings.catalogs if c.id == cfg_id), None)
147-
if cfg and hasattr(cfg, "min_items") and hasattr(cfg, "max_items"):
148-
return int(cfg.min_items or DEFAULT_MIN_ITEMS), int(cfg.max_items or DEFAULT_MAX_ITEMS)
149-
except Exception:
150-
pass
151-
return DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
48+
# Delegate to catalog service facade
49+
recommendations, headers = await _catalog_service.get_catalog(token, type, id)
15250

153-
min_items, max_items = _get_limits()
154-
# Enforce caps: min_items <= 20, max_items <= 32 and max >= min
155-
try:
156-
min_items = max(1, min(DEFAULT_MIN_ITEMS, int(min_items)))
157-
max_items = max(min_items, min(DEFAULT_MAX_ITEMS, int(max_items)))
158-
except (ValueError, TypeError):
159-
logger.warning(
160-
"Invalid min/max items values. Falling back to defaults. "
161-
f"min_items={min_items}, max_items={max_items}"
162-
)
163-
min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
51+
# Set response headers
52+
for key, value in headers.items():
53+
response.headers[key] = value
16454

165-
# Handle item-based recommendations
166-
if id.startswith("tt") or any(
167-
id.startswith(p)
168-
for p in (
169-
"watchly.item.",
170-
"watchly.loved.",
171-
"watchly.watched.",
172-
)
173-
):
174-
# Extract actual item ID
175-
if id.startswith("tt"):
176-
item_id = id
177-
else:
178-
item_id = re.sub(r"^watchly\.(item|loved|watched)\.", "", id)
179-
180-
# Get watched sets
181-
_, watched_tmdb, watched_imdb = await integration.build_profile_from_library(
182-
library_items, type, bundle, auth_key
183-
)
184-
185-
# Get genre whitelist
186-
whitelist = await integration.get_genre_whitelist(library_items, type, bundle, auth_key)
187-
188-
# Use new item-based service
189-
recommendations = await item_service.get_recommendations_for_item(
190-
item_id=item_id,
191-
content_type=type,
192-
watched_tmdb=watched_tmdb,
193-
watched_imdb=watched_imdb,
194-
limit=max_items,
195-
integration=integration,
196-
library_items=library_items,
197-
)
198-
199-
if len(recommendations) < min_items:
200-
recommendations = await pad_to_min(
201-
type, recommendations, min_items, tmdb_service, user_settings, bundle, library_items, auth_key
202-
)
203-
logger.info(f"Found {len(recommendations)} recommendations for item {item_id}")
204-
205-
elif id.startswith("watchly.theme."):
206-
# Build profile for theme-based recommendations
207-
profile, watched_tmdb, watched_imdb = await integration.build_profile_from_library(
208-
library_items, type, bundle, auth_key
209-
)
210-
211-
# Use new theme-based service
212-
recommendations = await theme_service.get_recommendations_for_theme(
213-
theme_id=id,
214-
content_type=type,
215-
profile=profile,
216-
watched_tmdb=watched_tmdb,
217-
watched_imdb=watched_imdb,
218-
limit=max_items,
219-
integration=integration,
220-
library_items=library_items,
221-
)
222-
223-
if len(recommendations) < min_items:
224-
recommendations = await pad_to_min(
225-
type, recommendations, min_items, tmdb_service, user_settings, bundle, library_items, auth_key
226-
)
227-
logger.info(f"Found {len(recommendations)} recommendations for theme {id}")
228-
229-
elif id == "watchly.creators":
230-
# Build profile for creators-based recommendations
231-
profile, watched_tmdb, watched_imdb = await integration.build_profile_from_library(
232-
library_items, type, bundle, auth_key
233-
)
234-
235-
# Get genre whitelist
236-
whitelist = await integration.get_genre_whitelist(library_items, type, bundle, auth_key)
237-
238-
if profile:
239-
# Use new creators service
240-
recommendations = await creators_service.get_recommendations_from_creators(
241-
profile=profile,
242-
content_type=type,
243-
library_items=library_items,
244-
watched_tmdb=watched_tmdb,
245-
watched_imdb=watched_imdb,
246-
whitelist=whitelist,
247-
limit=max_items,
248-
)
249-
else:
250-
# No profile available, return empty
251-
recommendations = []
252-
253-
if len(recommendations) < min_items:
254-
recommendations = await pad_to_min(
255-
type, recommendations, min_items, tmdb_service, user_settings, bundle, library_items, auth_key
256-
)
257-
logger.info(f"Found {len(recommendations)} recommendations from creators")
258-
259-
elif id == "watchly.rec":
260-
# Top picks - use new TopPicksService
261-
profile, watched_tmdb, watched_imdb = await integration.build_profile_from_library(
262-
library_items, type, bundle, auth_key
263-
)
264-
265-
if profile:
266-
recommendations = await top_picks_service.get_top_picks(
267-
profile=profile,
268-
content_type=type,
269-
library_items=library_items,
270-
watched_tmdb=watched_tmdb,
271-
watched_imdb=watched_imdb,
272-
limit=max_items,
273-
)
274-
else:
275-
# No profile available, return empty
276-
recommendations = []
277-
278-
if len(recommendations) < min_items:
279-
recommendations = await pad_to_min(
280-
type, recommendations, min_items, tmdb_service, user_settings, bundle, library_items, auth_key
281-
)
282-
logger.info(f"Found {len(recommendations)} top picks for {type}")
283-
284-
else:
285-
# Unknown catalog ID, return empty
286-
logger.warning(f"Unknown catalog ID: {id}")
287-
recommendations = []
288-
289-
logger.info(f"Returning {len(recommendations)} items for {type}")
290-
response.headers["Cache-Control"] = "public, max-age=21600" # 6 hours
55+
# Clean and format metadata
29156
cleaned = [_clean_meta(m) for m in recommendations]
292-
# remove none values
29357
cleaned = [m for m in cleaned if m is not None]
58+
29459
return {"metas": cleaned}
29560

29661
except HTTPException:
29762
raise
29863
except Exception as e:
29964
logger.exception(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}")
30065
raise HTTPException(status_code=500, detail=str(e))
301-
finally:
302-
await bundle.close()

app/services/profile/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,6 @@
6060
TOP_PICKS_ERA_CAP: Final[float] = 0.40 # Max 40% per era
6161
TOP_PICKS_MIN_VOTE_COUNT: Final[int] = 100 # Minimum vote count for quality
6262
TOP_PICKS_MIN_RATING: Final[float] = 5.0 # Minimum weighted rating for quality
63+
64+
# Genre whitelist limit (top N genres)
65+
GENRE_WHITELIST_LIMIT: Final[int] = 5

app/services/profile/integration.py

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,28 @@
1-
"""
2-
Integration helper for taste profile services.
3-
4-
Provides utilities to build profiles and prepare data for recommendation services.
5-
"""
6-
71
from typing import Any
82

93
from loguru import logger
104

115
from app.models.taste_profile import TasteProfile
126
from app.services.profile.builder import ProfileBuilder
7+
from app.services.profile.constants import GENRE_WHITELIST_LIMIT
138
from app.services.profile.sampling import SmartSampler
149
from app.services.profile.vectorizer import ItemVectorizer
1510
from app.services.recommendation.filtering import RecommendationFiltering
1611
from app.services.scoring import ScoringService
1712
from app.services.tmdb.service import get_tmdb_service
1813

19-
# Genre whitelist limit (top N genres)
20-
GENRE_WHITELIST_LIMIT = 5
21-
2214

2315
class ProfileIntegration:
2416
"""
2517
Helper class to integrate taste profile services with existing systems.
2618
"""
2719

2820
def __init__(self, language: str = "en-US"):
29-
"""
30-
Initialize integration helper.
31-
32-
Args:
33-
language: Language for TMDB service
34-
"""
3521
self.scoring_service = ScoringService()
3622
self.sampler = SmartSampler(self.scoring_service)
3723
tmdb_service = get_tmdb_service(language=language)
3824
vectorizer = ItemVectorizer(tmdb_service)
3925
self.builder = ProfileBuilder(vectorizer)
40-
self._whitelist_cache: dict[str, set[int]] = {}
4126

4227
async def build_profile_from_library(
4328
self,
@@ -91,44 +76,27 @@ async def build_profile_from_library(
9176

9277
async def get_genre_whitelist(
9378
self,
94-
library_items: dict,
79+
profile: TasteProfile,
9580
content_type: str,
96-
stremio_service: Any = None,
97-
auth_key: str | None = None,
9881
) -> set[int]:
9982
"""
10083
Get genre whitelist from user's top genres in profile.
10184
10285
Args:
103-
library_items: Library items dict from Stremio
86+
profile: Taste profile
10487
content_type: Content type (movie/series)
105-
stremio_service: Stremio service (optional)
106-
auth_key: Auth key (optional)
10788
10889
Returns:
109-
Set of top genre IDs (empty if no profile)
90+
Set of top genre IDs
11091
"""
111-
# Check cache
112-
cache_key = f"{content_type}"
113-
if cache_key in self._whitelist_cache:
114-
return self._whitelist_cache[cache_key]
115-
11692
try:
117-
# Build profile
118-
profile, _, _ = await self.build_profile_from_library(
119-
library_items, content_type, stremio_service, auth_key
120-
)
121-
12293
if not profile:
12394
whitelist = set()
12495
else:
12596
# Get top genres
12697
top_genres = profile.get_top_genres(limit=GENRE_WHITELIST_LIMIT)
12798
whitelist = {int(genre_id) for genre_id, _ in top_genres}
128-
129-
# Cache result
130-
self._whitelist_cache[cache_key] = whitelist
131-
return whitelist
99+
return whitelist
132100
except Exception as e:
133101
logger.warning(f"Failed to build genre whitelist for {content_type}: {e}")
134102
return set()

0 commit comments

Comments
 (0)