|
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.recommendation.engine import RecommendationEngine |
12 | | -from app.services.stremio.service import StremioBundle |
13 | | -from app.services.token_store import token_store |
14 | | - |
15 | | -MAX_RESULTS = 50 |
16 | | -DEFAULT_MIN_ITEMS = 20 |
17 | | -DEFAULT_MAX_ITEMS = 32 |
18 | | -SOURCE_ITEMS_LIMIT = 10 |
| 5 | +from app.services.recommendation.catalog_service import CatalogService |
19 | 6 |
|
20 | 7 | router = APIRouter() |
21 | 8 |
|
| 9 | +# Initialize catalog service (singleton) |
| 10 | +_catalog_service = CatalogService() |
| 11 | + |
22 | 12 |
|
23 | 13 | def _clean_meta(meta: dict) -> dict: |
24 | 14 | """Return a sanitized Stremio meta object without internal fields. |
@@ -49,164 +39,27 @@ def _clean_meta(meta: dict) -> dict: |
49 | 39 |
|
50 | 40 | @router.get("/{token}/catalog/{type}/{id}.json") |
51 | 41 | async def get_catalog(type: str, id: str, response: Response, token: str): |
52 | | - if not token: |
53 | | - raise HTTPException( |
54 | | - status_code=400, |
55 | | - detail="Missing credentials token. Please open Watchly from a configured manifest URL.", |
56 | | - ) |
57 | | - |
58 | | - if type not in ["movie", "series"]: |
59 | | - logger.warning(f"Invalid type: {type}") |
60 | | - raise HTTPException(status_code=400, detail="Invalid type. Use 'movie' or 'series'") |
61 | | - |
62 | | - # Supported IDs now include dynamic themes and item-based rows |
63 | | - if id != "watchly.rec" and not any( |
64 | | - id.startswith(p) |
65 | | - for p in ( |
66 | | - "tt", |
67 | | - "watchly.theme.", |
68 | | - "watchly.item.", |
69 | | - "watchly.loved.", |
70 | | - "watchly.watched.", |
71 | | - ) |
72 | | - ): |
73 | | - logger.warning(f"Invalid id: {id}") |
74 | | - raise HTTPException( |
75 | | - status_code=400, |
76 | | - detail=( # |
77 | | - "Invalid id. Supported: 'watchly.rec', 'watchly.theme.<params>', 'watchly.item.<id>', or" |
78 | | - " specific item IDs." |
79 | | - ), |
80 | | - ) |
81 | | - |
82 | | - logger.info(f"[{redact_token(token)}] Fetching catalog for {type} with id {id}") |
83 | | - |
84 | | - credentials = await token_store.get_user_data(token) |
85 | | - if not credentials: |
86 | | - raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.") |
87 | | - |
88 | | - # Trigger lazy update if needed |
89 | | - if settings.AUTO_UPDATE_CATALOGS: |
90 | | - await catalog_updater.trigger_update(token, credentials) |
| 42 | + """ |
| 43 | + Get catalog recommendations. |
91 | 44 |
|
92 | | - bundle = StremioBundle() |
| 45 | + This endpoint delegates all logic to CatalogService facade. |
| 46 | + """ |
93 | 47 | try: |
94 | | - # 1. Resolve Auth Key (with potential fallback to login) |
95 | | - auth_key = credentials.get("authKey") |
96 | | - email = credentials.get("email") |
97 | | - password = credentials.get("password") |
98 | | - |
99 | | - is_valid = False |
100 | | - if auth_key: |
101 | | - try: |
102 | | - await bundle.auth.get_user_info(auth_key) |
103 | | - is_valid = True |
104 | | - except Exception: |
105 | | - pass |
| 48 | + # Delegate to catalog service facade |
| 49 | + recommendations, headers = await _catalog_service.get_catalog(token, type, id) |
106 | 50 |
|
107 | | - if not is_valid and email and password: |
108 | | - try: |
109 | | - auth_key = await bundle.auth.login(email, password) |
110 | | - credentials["authKey"] = auth_key |
111 | | - await token_store.update_user_data(token, credentials) |
112 | | - except Exception as e: |
113 | | - logger.error(f"Failed to refresh auth key during catalog fetch: {e}") |
| 51 | + # Set response headers |
| 52 | + for key, value in headers.items(): |
| 53 | + response.headers[key] = value |
114 | 54 |
|
115 | | - if not auth_key: |
116 | | - raise HTTPException(status_code=401, detail="Stremio session expired. Please reconfigure.") |
117 | | - |
118 | | - # 2. Extract settings from credentials |
119 | | - settings_dict = credentials.get("settings", {}) |
120 | | - user_settings = UserSettings(**settings_dict) if settings_dict else get_default_settings() |
121 | | - language = user_settings.language if user_settings else "en-US" |
122 | | - |
123 | | - # 3. Fetch library once per request and reuse across recommendation paths |
124 | | - library_items = await bundle.library.get_library_items(auth_key) |
125 | | - engine = RecommendationEngine( |
126 | | - stremio_service=bundle, |
127 | | - language=language, |
128 | | - user_settings=user_settings, |
129 | | - token=token, |
130 | | - library_data=library_items, |
131 | | - ) |
132 | | - |
133 | | - # Resolve per-catalog limits (min/max) |
134 | | - def _get_limits() -> tuple[int, int]: |
135 | | - try: |
136 | | - cfg_id = get_config_id({"id": id}) |
137 | | - except Exception: |
138 | | - cfg_id = id |
139 | | - try: |
140 | | - cfg = next((c for c in user_settings.catalogs if c.id == cfg_id), None) |
141 | | - if cfg and hasattr(cfg, "min_items") and hasattr(cfg, "max_items"): |
142 | | - return int(cfg.min_items or DEFAULT_MIN_ITEMS), int(cfg.max_items or DEFAULT_MAX_ITEMS) |
143 | | - except Exception: |
144 | | - pass |
145 | | - return DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS |
146 | | - |
147 | | - min_items, max_items = _get_limits() |
148 | | - # Enforce caps: min_items <= 20, max_items <= 32 and max >= min |
149 | | - try: |
150 | | - min_items = max(1, min(DEFAULT_MIN_ITEMS, int(min_items))) |
151 | | - max_items = max(min_items, min(DEFAULT_MAX_ITEMS, int(max_items))) |
152 | | - except (ValueError, TypeError): |
153 | | - logger.warning( |
154 | | - "Invalid min/max items values. Falling back to defaults. " |
155 | | - f"min_items={min_items}, max_items={max_items}" |
156 | | - ) |
157 | | - min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS |
158 | | - |
159 | | - # Handle item-based recommendations |
160 | | - if id.startswith("tt"): |
161 | | - engine.per_item_limit = max_items |
162 | | - recommendations = await engine.get_recommendations_for_item(item_id=id, media_type=type) |
163 | | - if len(recommendations) < min_items: |
164 | | - recommendations = await engine.pad_to_min(type, recommendations, min_items) |
165 | | - logger.info(f"Found {len(recommendations)} recommendations for {id}") |
166 | | - |
167 | | - elif any( |
168 | | - id.startswith(p) |
169 | | - for p in ( |
170 | | - "watchly.item.", |
171 | | - "watchly.loved.", |
172 | | - "watchly.watched.", |
173 | | - ) |
174 | | - ): |
175 | | - # Extract actual item ID (tt... or tmdb:...) |
176 | | - item_id = re.sub(r"^watchly\.(item|loved|watched)\.", "", id) |
177 | | - engine.per_item_limit = max_items |
178 | | - recommendations = await engine.get_recommendations_for_item(item_id=item_id, media_type=type) |
179 | | - if len(recommendations) < min_items: |
180 | | - recommendations = await engine.pad_to_min(type, recommendations, min_items) |
181 | | - logger.info(f"Found {len(recommendations)} recommendations for item {item_id}") |
182 | | - |
183 | | - elif id.startswith("watchly.theme."): |
184 | | - recommendations = await engine.get_recommendations_for_theme( |
185 | | - theme_id=id, content_type=type, limit=max_items |
186 | | - ) |
187 | | - if len(recommendations) < min_items: |
188 | | - recommendations = await engine.pad_to_min(type, recommendations, min_items) |
189 | | - logger.info(f"Found {len(recommendations)} recommendations for theme {id}") |
190 | | - |
191 | | - else: |
192 | | - recommendations = await engine.get_recommendations( |
193 | | - content_type=type, source_items_limit=SOURCE_ITEMS_LIMIT, max_results=max_items |
194 | | - ) |
195 | | - if len(recommendations) < min_items: |
196 | | - recommendations = await engine.pad_to_min(type, recommendations, min_items) |
197 | | - logger.info(f"Found {len(recommendations)} recommendations for {type}") |
198 | | - |
199 | | - logger.info(f"Returning {len(recommendations)} items for {type}") |
200 | | - response.headers["Cache-Control"] = "public, max-age=21600" # 6 hours |
| 55 | + # Clean and format metadata |
201 | 56 | cleaned = [_clean_meta(m) for m in recommendations] |
202 | | - # remove none values |
203 | 57 | cleaned = [m for m in cleaned if m is not None] |
| 58 | + |
204 | 59 | return {"metas": cleaned} |
205 | 60 |
|
206 | 61 | except HTTPException: |
207 | 62 | raise |
208 | 63 | except Exception as e: |
209 | 64 | logger.exception(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}") |
210 | 65 | raise HTTPException(status_code=500, detail=str(e)) |
211 | | - finally: |
212 | | - await bundle.close() |
0 commit comments