Skip to content

Commit ca46319

Browse files
feat: implement new recommendation services and profile system
1 parent 3de03f3 commit ca46319

26 files changed

+3206
-1016
lines changed

app/api/endpoints/catalogs.py

Lines changed: 123 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@
88
from app.core.security import redact_token
99
from app.core.settings import UserSettings, get_default_settings
1010
from app.services.catalog_updater import catalog_updater
11-
from app.services.recommendation.engine import RecommendationEngine
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
1217
from app.services.stremio.service import StremioBundle
18+
from app.services.tmdb.service import get_tmdb_service
1319
from app.services.token_store import token_store
1420

1521
MAX_RESULTS = 50
1622
DEFAULT_MIN_ITEMS = 20
1723
DEFAULT_MAX_ITEMS = 32
18-
SOURCE_ITEMS_LIMIT = 10
1924

2025
router = APIRouter()
2126

@@ -60,7 +65,7 @@ async def get_catalog(type: str, id: str, response: Response, token: str):
6065
raise HTTPException(status_code=400, detail="Invalid type. Use 'movie' or 'series'")
6166

6267
# Supported IDs now include dynamic themes and item-based rows
63-
if id != "watchly.rec" and not any(
68+
if id not in ["watchly.rec", "watchly.creators"] and not any(
6469
id.startswith(p)
6570
for p in (
6671
"tt",
@@ -74,8 +79,8 @@ async def get_catalog(type: str, id: str, response: Response, token: str):
7479
raise HTTPException(
7580
status_code=400,
7681
detail=( #
77-
"Invalid id. Supported: 'watchly.rec', 'watchly.theme.<params>', 'watchly.item.<id>', or"
78-
" specific item IDs."
82+
"Invalid id. Supported: 'watchly.rec', 'watchly.creators', 'watchly.theme.<params>',"
83+
"'watchly.item.<id>', or specific item IDs."
7984
),
8085
)
8186

@@ -122,13 +127,14 @@ async def get_catalog(type: str, id: str, response: Response, token: str):
122127

123128
# 3. Fetch library once per request and reuse across recommendation paths
124129
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-
)
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)
132138

133139
# Resolve per-catalog limits (min/max)
134140
def _get_limits() -> tuple[int, int]:
@@ -157,44 +163,128 @@ def _get_limits() -> tuple[int, int]:
157163
min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
158164

159165
# 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(
166+
if id.startswith("tt") or any(
168167
id.startswith(p)
169168
for p in (
170169
"watchly.item.",
171170
"watchly.loved.",
172171
"watchly.watched.",
173172
)
174173
):
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)
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+
179199
if len(recommendations) < min_items:
180-
recommendations = await engine.pad_to_min(type, recommendations, min_items)
200+
recommendations = await pad_to_min(
201+
type, recommendations, min_items, tmdb_service, user_settings, bundle, library_items, auth_key
202+
)
181203
logger.info(f"Found {len(recommendations)} recommendations for item {item_id}")
182204

183205
elif id.startswith("watchly.theme."):
184-
recommendations = await engine.get_recommendations_for_theme(
185-
theme_id=id, content_type=type, limit=max_items
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,
186221
)
222+
187223
if len(recommendations) < min_items:
188-
recommendations = await engine.pad_to_min(type, recommendations, min_items)
224+
recommendations = await pad_to_min(
225+
type, recommendations, min_items, tmdb_service, user_settings, bundle, library_items, auth_key
226+
)
189227
logger.info(f"Found {len(recommendations)} recommendations for theme {id}")
190228

191-
else:
192-
recommendations = await engine.get_recommendations(
193-
content_type=type, source_items_limit=SOURCE_ITEMS_LIMIT, max_results=max_items
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
194263
)
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+
195278
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}")
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 = []
198288

199289
logger.info(f"Returning {len(recommendations)} items for {type}")
200290
response.headers["Cache-Control"] = "public, max-age=21600" # 6 hours

app/api/endpoints/manifest.py

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,40 +15,38 @@
1515

1616

1717
def get_base_manifest(user_settings: UserSettings | None = None):
18-
# Default catalog config
19-
rec_config = None
18+
catalogs = []
19+
2020
if user_settings:
21-
# Find config for 'recommended'
22-
rec_config = next((c for c in user_settings.catalogs if c.id == "watchly.rec"), None)
2321

24-
# If disabled explicitly, don't include it.
25-
# If not configured (None), default to enabled.
26-
if rec_config and not rec_config.enabled:
27-
catalogs = []
22+
print(user_settings)
23+
# Handle watchly.rec
24+
rec_config = next((c for c in user_settings.catalogs if c.id == "watchly.rec"), None)
25+
if not rec_config or rec_config.enabled:
26+
name = rec_config.name if rec_config and rec_config.name else "Top Picks for You"
27+
enabled_movie = getattr(rec_config, "enabled_movie", True) if rec_config else True
28+
enabled_series = getattr(rec_config, "enabled_series", True) if rec_config else True
29+
30+
if enabled_movie:
31+
catalogs.append({"type": "movie", "id": "watchly.rec", "name": name, "extra": []})
32+
if enabled_series:
33+
catalogs.append({"type": "series", "id": "watchly.rec", "name": name, "extra": []})
34+
35+
# Handle watchly.creators
36+
creators_config = next((c for c in user_settings.catalogs if c.id == "watchly.creators"), None)
37+
if not creators_config or creators_config.enabled:
38+
name = creators_config.name if creators_config and creators_config.name else "From your favourite Creators"
39+
enabled_movie = getattr(creators_config, "enabled_movie", True) if creators_config else True
40+
enabled_series = getattr(creators_config, "enabled_series", True) if creators_config else True
41+
42+
if enabled_movie:
43+
catalogs.append({"type": "movie", "id": "watchly.creators", "name": name, "extra": []})
44+
if enabled_series:
45+
catalogs.append({"type": "series", "id": "watchly.creators", "name": name, "extra": []})
2846
else:
29-
name = rec_config.name if rec_config and rec_config.name else "Top Picks for You"
30-
enabled_movie = getattr(rec_config, "enabled_movie", True) if rec_config else True
31-
enabled_series = getattr(rec_config, "enabled_series", True) if rec_config else True
32-
33-
catalogs = []
34-
if enabled_movie:
35-
catalogs.append(
36-
{
37-
"type": "movie",
38-
"id": "watchly.rec",
39-
"name": name,
40-
"extra": [],
41-
}
42-
)
43-
if enabled_series:
44-
catalogs.append(
45-
{
46-
"type": "series",
47-
"id": "watchly.rec",
48-
"name": name,
49-
"extra": [],
50-
}
51-
)
47+
# Default: include watchly.rec
48+
catalogs.append({"type": "movie", "id": "watchly.rec", "name": "Top Picks for You", "extra": []})
49+
catalogs.append({"type": "series", "id": "watchly.rec", "name": "Top Picks for You", "extra": []})
5250

5351
return {
5452
"id": settings.ADDON_ID,
@@ -79,7 +77,7 @@ async def build_dynamic_catalogs(bundle: StremioBundle, auth_key: str, user_sett
7977

8078

8179
async def _manifest_handler(response: Response, token: str):
82-
response.headers["Cache-Control"] = "public, max-age=300" # 5 minutes
80+
# response.headers["Cache-Control"] = "public, max-age=300" # 5 minutes
8381

8482
if not token:
8583
raise HTTPException(status_code=401, detail="Missing token. Please reconfigure the addon.")
@@ -96,6 +94,8 @@ async def _manifest_handler(response: Response, token: str):
9694
if not creds:
9795
raise HTTPException(status_code=401, detail="Token not found. Please reconfigure the addon.")
9896

97+
logger.info(f"[{token}] User settings: {user_settings}")
98+
9999
base_manifest = get_base_manifest(user_settings)
100100

101101
bundle = StremioBundle()

app/core/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ def get_default_settings() -> UserSettings:
3737
enabled_movie=True,
3838
enabled_series=True,
3939
),
40+
CatalogConfig(
41+
id="watchly.creators",
42+
name="From your favourite Creators",
43+
enabled=True,
44+
enabled_movie=True,
45+
enabled_series=True,
46+
),
4047
],
4148
)
4249

app/models/profile.py

Lines changed: 0 additions & 81 deletions
This file was deleted.

0 commit comments

Comments
 (0)