Skip to content

Commit f0b2161

Browse files
feat: add loved and liked all recommendation catalogs (#85)
* feat: add loved and liked all recommendation catalogs * chore: bump version to v1.5.0 * feat: enhance dynamic catalog building and improve error handling * refactor: introduce constants for item limits in recommendation services
1 parent 6c2e7ab commit f0b2161

File tree

8 files changed

+282
-54
lines changed

8 files changed

+282
-54
lines changed

app/api/endpoints/manifest.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,22 @@
11
from datetime import datetime, timezone
22

3-
from fastapi import HTTPException, Response
3+
from fastapi import HTTPException
44
from fastapi.routing import APIRouter
55
from loguru import logger
66

77
from app.core.config import settings
8-
from app.core.settings import UserSettings, get_default_settings
8+
from app.core.settings import UserSettings
99
from app.core.version import __version__
1010
from app.services.catalog import DynamicCatalogService
1111
from app.services.catalog_updater import get_config_id
1212
from app.services.stremio.service import StremioBundle
1313
from app.services.token_store import token_store
1414
from app.services.translation import translation_service
15-
from app.utils.catalog import get_catalogs_from_config
1615

1716
router = APIRouter()
1817

1918

20-
def get_base_manifest(user_settings: UserSettings | None = None):
21-
catalogs = []
22-
23-
if user_settings:
24-
catalogs.extend(get_catalogs_from_config(user_settings, "watchly.rec", "Top Picks for You", True, True))
25-
catalogs.extend(
26-
get_catalogs_from_config(user_settings, "watchly.creators", "From your favourite Creators", False, False)
27-
)
28-
else:
29-
# Default: empty catalogs
30-
catalogs = []
31-
19+
def get_base_manifest():
3220
return {
3321
"id": settings.ADDON_ID,
3422
"version": __version__,
@@ -42,7 +30,7 @@ def get_base_manifest(user_settings: UserSettings | None = None):
4230
"resources": ["catalog"],
4331
"types": ["movie", "series"],
4432
"idPrefixes": ["tt"],
45-
"catalogs": catalogs,
33+
"catalogs": [],
4634
"behaviorHints": {"configurable": True, "configurationRequired": False},
4735
"stremioAddonsConfig": {
4836
"issuer": "https://stremio-addons.net",
@@ -53,16 +41,22 @@ def get_base_manifest(user_settings: UserSettings | None = None):
5341
}
5442

5543

56-
async def build_dynamic_catalogs(bundle: StremioBundle, auth_key: str, user_settings: UserSettings) -> list[dict]:
44+
async def build_dynamic_catalogs(
45+
bundle: StremioBundle, auth_key: str, user_settings: UserSettings | None
46+
) -> list[dict]:
5747
# Fetch library using bundle directly
48+
if not user_settings:
49+
logger.error("User settings not found. Please reconfigure the addon.")
50+
raise HTTPException(status_code=401, detail="User settings not found. Please reconfigure the addon.")
51+
5852
library_items = await bundle.library.get_library_items(auth_key)
5953
dynamic_catalog_service = DynamicCatalogService(
6054
language=user_settings.language,
6155
)
6256
return await dynamic_catalog_service.get_dynamic_catalogs(library_items, user_settings)
6357

6458

65-
async def _manifest_handler(response: Response, token: str):
59+
async def _manifest_handler(token: str):
6660
# response.headers["Cache-Control"] = "public, max-age=300" # 5 minutes
6761
if not token:
6862
raise HTTPException(status_code=401, detail="Missing token. Please reconfigure the addon.")
@@ -79,7 +73,7 @@ async def _manifest_handler(response: Response, token: str):
7973
if not creds:
8074
raise HTTPException(status_code=401, detail="Token not found. Please reconfigure the addon.")
8175

82-
base_manifest = get_base_manifest(user_settings)
76+
base_manifest = get_base_manifest()
8377

8478
bundle = StremioBundle()
8579
fetched_catalogs = []
@@ -111,7 +105,7 @@ async def _manifest_handler(response: Response, token: str):
111105
fetched_catalogs = await build_dynamic_catalogs(
112106
bundle,
113107
auth_key,
114-
user_settings or get_default_settings(),
108+
user_settings,
115109
)
116110
except Exception as e:
117111
logger.exception(f"[{token}] Dynamic catalog build failed: {e}")
@@ -154,5 +148,5 @@ async def manifest():
154148

155149

156150
@router.get("/{token}/manifest.json")
157-
async def manifest_token(response: Response, token: str):
158-
return await _manifest_handler(response, token)
151+
async def manifest_token(token: str):
152+
return await _manifest_handler(token)

app/core/settings.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,25 @@ def get_default_settings() -> UserSettings:
2424
language="en-US",
2525
catalogs=[
2626
CatalogConfig(
27-
id="watchly.rec", name="Top Picks for You", enabled=True, enabled_movie=True, enabled_series=True
27+
id="watchly.rec",
28+
name="Top Picks for You",
29+
enabled=True,
30+
enabled_movie=True,
31+
enabled_series=True,
32+
),
33+
CatalogConfig(
34+
id="watchly.loved",
35+
name="More Like",
36+
enabled=True,
37+
enabled_movie=True,
38+
enabled_series=True,
2839
),
29-
CatalogConfig(id="watchly.loved", name="More Like", enabled=True, enabled_movie=True, enabled_series=True),
3040
CatalogConfig(
31-
id="watchly.watched", name="Because you watched", enabled=True, enabled_movie=True, enabled_series=True
41+
id="watchly.watched",
42+
name="Because you watched",
43+
enabled=True,
44+
enabled_movie=True,
45+
enabled_series=True,
3246
),
3347
CatalogConfig(
3448
id="watchly.theme",
@@ -40,7 +54,21 @@ def get_default_settings() -> UserSettings:
4054
CatalogConfig(
4155
id="watchly.creators",
4256
name="From your favourite Creators",
43-
enabled=True,
57+
enabled=False,
58+
enabled_movie=True,
59+
enabled_series=True,
60+
),
61+
CatalogConfig(
62+
id="watchly.all.loved",
63+
name="Based on what you loved",
64+
enabled=False,
65+
enabled_movie=True,
66+
enabled_series=True,
67+
),
68+
CatalogConfig(
69+
id="watchly.liked.all",
70+
name="Based on what you liked",
71+
enabled=False,
4472
enabled_movie=True,
4573
enabled_series=True,
4674
),

app/core/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.4.5"
1+
__version__ = "1.5.0"

app/services/catalog.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from app.services.row_generator import RowGeneratorService
1111
from app.services.scoring import ScoringService
1212
from app.services.tmdb.service import get_tmdb_service
13+
from app.utils.catalog import get_catalogs_from_config
1314

1415

1516
class DynamicCatalogService:
@@ -181,6 +182,24 @@ async def get_dynamic_catalogs(self, library_items: dict, user_settings: UserSet
181182
for mtype in ["movie", "series"]:
182183
await self._add_item_based_rows(catalogs, library_items, mtype, loved_cfg, watched_cfg)
183184

185+
# 4. Add watchly.rec catalog
186+
catalogs.extend(get_catalogs_from_config(user_settings, "watchly.rec", "Top Picks for You", True, True))
187+
188+
# 5. Add watchly.creators catalog
189+
catalogs.extend(
190+
get_catalogs_from_config(user_settings, "watchly.creators", "From your favourite Creators", False, False)
191+
)
192+
193+
# 6. Add watchly.all.loved catalog
194+
catalogs.extend(
195+
get_catalogs_from_config(user_settings, "watchly.all.loved", "Based on what you loved", True, True)
196+
)
197+
198+
# 7. Add watchly.liked.all catalog
199+
catalogs.extend(
200+
get_catalogs_from_config(user_settings, "watchly.liked.all", "Based on what you liked", True, True)
201+
)
202+
184203
return catalogs
185204

186205
def _resolve_catalog_configs(self, user_settings: UserSettings) -> tuple[Any, Any, Any]:

app/services/catalog_updater.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from app.services.stremio.service import StremioBundle
1313
from app.services.token_store import token_store
1414
from app.services.translation import translation_service
15-
from app.utils.catalog import get_catalogs_from_config
1615

1716

1817
def get_config_id(catalog) -> str | None:
@@ -23,10 +22,6 @@ def get_config_id(catalog) -> str | None:
2322
return "watchly.loved"
2423
if catalog_id.startswith("watchly.watched."):
2524
return "watchly.watched"
26-
if catalog_id.startswith("watchly.item."):
27-
return "watchly.item"
28-
if catalog_id.startswith("watchly.rec"):
29-
return "watchly.rec"
3025
return catalog_id
3126

3227

@@ -134,15 +129,6 @@ async def refresh_catalogs_for_credentials(
134129
library_items=library_items, user_settings=user_settings
135130
)
136131

137-
# now add the default catalogs
138-
if user_settings:
139-
catalogs.extend(get_catalogs_from_config(user_settings, "watchly.rec", "Top Picks for You", True, True))
140-
catalogs.extend(
141-
get_catalogs_from_config(
142-
user_settings, "watchly.creators", "From your favourite Creators", False, False
143-
)
144-
)
145-
146132
# Translate catalogs
147133
if user_settings and user_settings.language:
148134
for cat in catalogs:
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import asyncio
2+
from typing import Any
3+
4+
from loguru import logger
5+
6+
from app.core.settings import UserSettings
7+
from app.models.taste_profile import TasteProfile
8+
from app.services.profile.scorer import ProfileScorer
9+
from app.services.recommendation.filtering import RecommendationFiltering
10+
from app.services.recommendation.metadata import RecommendationMetadata
11+
from app.services.recommendation.scoring import RecommendationScoring
12+
from app.services.recommendation.utils import (
13+
content_type_to_mtype,
14+
filter_by_genres,
15+
filter_watched_by_imdb,
16+
resolve_tmdb_id,
17+
)
18+
from app.services.tmdb.service import TMDBService
19+
20+
TOP_ITEMS_LIMIT = 10
21+
22+
23+
class AllBasedService:
24+
"""
25+
Handles recommendations based on all loved or all liked items.
26+
"""
27+
28+
def __init__(self, tmdb_service: TMDBService, user_settings: UserSettings | None = None):
29+
self.tmdb_service = tmdb_service
30+
self.user_settings = user_settings
31+
self.scorer = ProfileScorer()
32+
33+
async def get_recommendations_from_all_items(
34+
self,
35+
library_items: dict[str, list[dict[str, Any]]],
36+
content_type: str,
37+
watched_tmdb: set[int],
38+
watched_imdb: set[str],
39+
whitelist: set[int] | None = None,
40+
limit: int = 20,
41+
item_type: str = "loved", # "loved" or "liked"
42+
profile: TasteProfile | None = None,
43+
) -> list[dict[str, Any]]:
44+
"""
45+
Get recommendations based on all loved or liked items.
46+
47+
Strategy:
48+
1. Get all loved/liked items for the content type
49+
2. Fetch recommendations for each item (limit to top 10 items to avoid too many API calls)
50+
3. Combine and deduplicate recommendations
51+
4. Filter by genres and watched items
52+
5. Return top N
53+
54+
Args:
55+
library_items: Library items dict
56+
content_type: Content type (movie/series)
57+
watched_tmdb: Set of watched TMDB IDs
58+
watched_imdb: Set of watched IMDB IDs
59+
whitelist: Genre whitelist
60+
limit: Number of items to return
61+
item_type: "loved" or "liked"
62+
profile: Optional profile for scoring (if None, uses popularity only)
63+
64+
Returns:
65+
List of recommended items
66+
"""
67+
# Get all loved or liked items for the content type
68+
items = library_items.get(item_type, [])
69+
typed_items = [it for it in items if it.get("type") == content_type]
70+
71+
if not typed_items or len(typed_items) == 0:
72+
return []
73+
74+
# We'll process them in parallel
75+
top_items = typed_items[:TOP_ITEMS_LIMIT]
76+
77+
mtype = content_type_to_mtype(content_type)
78+
79+
# Fetch recommendations for each item in parallel
80+
all_candidates = {}
81+
tasks = []
82+
83+
for item in top_items:
84+
item_id = item.get("_id", "")
85+
if not item_id:
86+
continue
87+
88+
# Resolve TMDB ID and fetch recommendations
89+
tasks.append(self._fetch_recommendations_for_item(item_id, mtype))
90+
91+
# Execute all in parallel
92+
results = await asyncio.gather(*tasks, return_exceptions=True)
93+
94+
# Combine all recommendations (deduplicate by TMDB ID)
95+
for res in results:
96+
if isinstance(res, Exception):
97+
logger.debug(f"Error fetching recommendations: {res}")
98+
continue
99+
for candidate in res:
100+
candidate_id = candidate.get("id")
101+
if candidate_id:
102+
all_candidates[candidate_id] = candidate
103+
104+
# Convert to list
105+
candidates = list(all_candidates.values())
106+
107+
# Filter by genres and watched items
108+
excluded_ids = RecommendationFiltering.get_excluded_genre_ids(self.user_settings, content_type)
109+
whitelist = whitelist or set()
110+
filtered = filter_by_genres(candidates, watched_tmdb, whitelist, excluded_ids)
111+
112+
# Score with profile if available
113+
if profile:
114+
scored = []
115+
for item in filtered:
116+
try:
117+
final_score = RecommendationScoring.calculate_final_score(
118+
item=item,
119+
profile=profile,
120+
scorer=self.scorer,
121+
mtype=mtype,
122+
is_ranked=False,
123+
is_fresh=False,
124+
)
125+
126+
# Apply genre multiplier (if whitelist available)
127+
genre_mult = RecommendationFiltering.get_genre_multiplier(item.get("genre_ids"), whitelist)
128+
final_score *= genre_mult
129+
130+
scored.append((final_score, item))
131+
except Exception as e:
132+
logger.debug(f"Failed to score item {item.get('id')}: {e}")
133+
continue
134+
135+
# Sort by score
136+
scored.sort(key=lambda x: x[0], reverse=True)
137+
filtered = [item for _, item in scored]
138+
139+
# Enrich metadata
140+
enriched = await RecommendationMetadata.fetch_batch(
141+
self.tmdb_service, filtered, content_type, user_settings=self.user_settings
142+
)
143+
144+
# Final filter (remove watched by IMDB ID)
145+
final = filter_watched_by_imdb(enriched, watched_imdb)
146+
147+
# Return top N
148+
return final[:limit]
149+
150+
async def _fetch_recommendations_for_item(self, item_id: str, mtype: str) -> list[dict[str, Any]]:
151+
"""
152+
Fetch recommendations for a single item.
153+
154+
Args:
155+
item_id: Item ID (tt... or tmdb:...)
156+
mtype: Media type (movie/tv)
157+
158+
Returns:
159+
List of candidate items
160+
"""
161+
# Resolve TMDB ID
162+
tmdb_id = await resolve_tmdb_id(item_id, self.tmdb_service)
163+
if not tmdb_id:
164+
return []
165+
166+
combined = {}
167+
168+
# Fetch 1 page each for recommendations
169+
try:
170+
res = await self.tmdb_service.get_recommendations(tmdb_id, mtype, page=1)
171+
for item in res.get("results", []):
172+
candidate_id = item.get("id")
173+
if candidate_id:
174+
combined[candidate_id] = item
175+
except Exception as e:
176+
logger.debug(f"Error fetching recommendations for {tmdb_id}: {e}")
177+
178+
return list(combined.values())

0 commit comments

Comments
 (0)