|
1 | | -import re |
2 | | - |
3 | 1 | from fastapi import APIRouter, HTTPException, Response |
4 | 2 | from loguru import logger |
5 | 3 |
|
6 | | -from app.api.endpoints.manifest import get_config_id |
7 | | -from app.core.config import settings |
8 | 4 | 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 |
24 | 6 |
|
25 | 7 | router = APIRouter() |
26 | 8 |
|
| 9 | +# Initialize catalog service (singleton) |
| 10 | +_catalog_service = CatalogService() |
| 11 | + |
27 | 12 |
|
28 | 13 | def _clean_meta(meta: dict) -> dict: |
29 | 14 | """Return a sanitized Stremio meta object without internal fields. |
@@ -54,249 +39,27 @@ def _clean_meta(meta: dict) -> dict: |
54 | 39 |
|
55 | 40 | @router.get("/{token}/catalog/{type}/{id}.json") |
56 | 41 | 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. |
96 | 44 |
|
97 | | - bundle = StremioBundle() |
| 45 | + This endpoint delegates all logic to CatalogService facade. |
| 46 | + """ |
98 | 47 | 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) |
152 | 50 |
|
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 |
164 | 54 |
|
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 |
291 | 56 | cleaned = [_clean_meta(m) for m in recommendations] |
292 | | - # remove none values |
293 | 57 | cleaned = [m for m in cleaned if m is not None] |
| 58 | + |
294 | 59 | return {"metas": cleaned} |
295 | 60 |
|
296 | 61 | except HTTPException: |
297 | 62 | raise |
298 | 63 | except Exception as e: |
299 | 64 | logger.exception(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}") |
300 | 65 | raise HTTPException(status_code=500, detail=str(e)) |
301 | | - finally: |
302 | | - await bundle.close() |
0 commit comments