Skip to content

Commit ab46217

Browse files
feat: add minimum rating thresholds for theme-based recommendations and streamline creator recommendation logic
1 parent d3cb3ac commit ab46217

File tree

4 files changed

+59
-173
lines changed

4 files changed

+59
-173
lines changed

app/core/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@
33
DEFAULT_MAX_ITEMS: int = 32
44

55
DEFAULT_CONCURRENCY_LIMIT: int = 30
6+
7+
8+
DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_MOVIE: float = 7.2
9+
DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_TV: float = 6.8

app/services/recommendation/catalog_service.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,8 @@ async def _get_recommendations(
279279
recommendations = await creators_service.get_recommendations_from_creators(
280280
profile=profile,
281281
content_type=content_type,
282-
library_items=library_items,
283282
watched_tmdb=watched_tmdb,
284283
watched_imdb=watched_imdb,
285-
whitelist=whitelist,
286284
limit=max_items,
287285
)
288286
else:
Lines changed: 47 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
1-
"""
2-
Creators-Based Recommendations Service.
3-
4-
Fetches recommendations from user's favorite directors and cast members.
5-
Uses frequency filtering to avoid single-appearance creators dominating.
6-
"""
7-
81
import asyncio
9-
from collections import defaultdict
102
from typing import Any
113

4+
from fastapi import HTTPException
125
from loguru import logger
136

7+
from app.core.settings import UserSettings
148
from app.models.taste_profile import TasteProfile
159
from app.services.recommendation.filtering import RecommendationFiltering
1610
from app.services.recommendation.metadata import RecommendationMetadata
17-
from app.services.recommendation.utils import content_type_to_mtype, filter_watched_by_imdb, resolve_tmdb_id
11+
from app.services.recommendation.utils import content_type_to_mtype, filter_watched_by_imdb
12+
from app.services.tmdb.service import TMDBService
1813

1914

2015
class CreatorsService:
@@ -30,25 +25,16 @@ class CreatorsService:
3025
6. Filter and return results
3126
"""
3227

33-
def __init__(self, tmdb_service: Any, user_settings: Any = None):
34-
"""
35-
Initialize creators service.
36-
37-
Args:
38-
tmdb_service: TMDB service for API calls
39-
user_settings: User settings for exclusions
40-
"""
41-
self.tmdb_service = tmdb_service
42-
self.user_settings = user_settings
28+
def __init__(self, tmdb_service: TMDBService, user_settings: UserSettings | None = None):
29+
self.tmdb_service: TMDBService = tmdb_service
30+
self.user_settings: UserSettings | None = user_settings
4331

4432
async def get_recommendations_from_creators(
4533
self,
4634
profile: TasteProfile,
4735
content_type: str,
48-
library_items: dict[str, list[dict[str, Any]]],
4936
watched_tmdb: set[int],
5037
watched_imdb: set[str],
51-
whitelist: set[int],
5238
limit: int = 20,
5339
) -> list[dict[str, Any]]:
5440
"""
@@ -57,94 +43,54 @@ async def get_recommendations_from_creators(
5743
Args:
5844
profile: User taste profile
5945
content_type: Content type (movie/series)
60-
library_items: Library items dict (for frequency counting)
6146
watched_tmdb: Set of watched TMDB IDs
6247
watched_imdb: Set of watched IMDB IDs
63-
whitelist: Genre whitelist
6448
limit: Number of recommendations to return
6549
6650
Returns:
6751
List of recommended items
6852
"""
6953
mtype = content_type_to_mtype(content_type)
7054

71-
# Get top directors and cast from profile
72-
top_directors = profile.get_top_directors(limit=20)
73-
top_cast = profile.get_top_cast(limit=20)
74-
75-
if not top_directors and not top_cast:
76-
return []
77-
78-
# Count raw frequencies to filter single-appearance creators
79-
director_frequencies, cast_frequencies = await self._count_creator_frequencies(library_items, content_type)
80-
81-
# Separate creators by frequency
82-
MIN_FREQUENCY = 2
83-
reliable_directors = [
84-
(dir_id, score) for dir_id, score in top_directors if director_frequencies.get(dir_id, 0) >= MIN_FREQUENCY
85-
]
86-
single_directors = [
87-
(dir_id, score) for dir_id, score in top_directors if director_frequencies.get(dir_id, 0) == 1
88-
]
89-
90-
reliable_cast = [
91-
(cast_id, score) for cast_id, score in top_cast if cast_frequencies.get(cast_id, 0) >= MIN_FREQUENCY
92-
]
93-
single_cast = [(cast_id, score) for cast_id, score in top_cast if cast_frequencies.get(cast_id, 0) == 1]
94-
95-
# Select top 5: prioritize reliable (2+), fill with single if needed
96-
selected_directors = reliable_directors[:5]
97-
remaining_director_slots = 5 - len(selected_directors)
98-
if remaining_director_slots > 0:
99-
selected_directors.extend(single_directors[:remaining_director_slots])
100-
101-
selected_cast = reliable_cast[:5]
102-
remaining_cast_slots = 5 - len(selected_cast)
103-
if remaining_cast_slots > 0:
104-
selected_cast.extend(single_cast[:remaining_cast_slots])
55+
# Get top 5 directors and cast directly from profile
56+
selected_directors = profile.get_top_directors(limit=5)
57+
selected_cast = profile.get_top_cast(limit=5)
10558

10659
if not selected_directors and not selected_cast:
107-
return []
60+
raise HTTPException(status_code=404, detail="No top directors or cast found")
10861

10962
# Fetch recommendations from creators
11063
all_candidates = {}
64+
tasks = []
11165

112-
# Fetch from directors
66+
# Create tasks for directors (fetch 2 pages each)
11367
for dir_id, _ in selected_directors:
114-
is_reliable = director_frequencies.get(dir_id, 0) >= MIN_FREQUENCY
115-
pages_to_fetch = [1, 2, 3] if is_reliable else [1] # Fewer pages for single-appearance
116-
117-
try:
118-
for page in pages_to_fetch:
119-
# TV uses with_people, movies use with_crew
120-
if mtype == "tv":
121-
discover_params = {"with_people": str(dir_id), "page": page}
122-
else:
123-
discover_params = {"with_crew": str(dir_id), "page": page}
124-
125-
results = await self.tmdb_service.get_discover(mtype, **discover_params)
126-
for item in results.get("results", []):
127-
item_id = item.get("id")
128-
if item_id:
129-
all_candidates[item_id] = item
130-
except Exception as e:
131-
logger.debug(f"Error fetching recommendations for director {dir_id}: {e}")
132-
133-
# Fetch from cast
68+
for page in [1, 2]:
69+
# TV uses with_people, movies use with_crew
70+
if mtype == "tv":
71+
discover_params = {"with_people": str(dir_id), "page": page}
72+
else:
73+
discover_params = {"with_crew": str(dir_id), "page": page}
74+
75+
tasks.append(self._fetch_discover_page(mtype, discover_params, dir_id, "director"))
76+
77+
# Create tasks for cast (fetch 2 pages each)
13478
for cast_id, _ in selected_cast:
135-
is_reliable = cast_frequencies.get(cast_id, 0) >= MIN_FREQUENCY
136-
pages_to_fetch = [1, 2, 3] if is_reliable else [1] # Fewer pages for single-appearance
137-
138-
try:
139-
for page in pages_to_fetch:
140-
discover_params = {"with_cast": str(cast_id), "page": page}
141-
results = await self.tmdb_service.get_discover(mtype, **discover_params)
142-
for item in results.get("results", []):
143-
item_id = item.get("id")
144-
if item_id:
145-
all_candidates[item_id] = item
146-
except Exception as e:
147-
logger.debug(f"Error fetching recommendations for cast {cast_id}: {e}")
79+
for page in [1, 2]:
80+
discover_params = {"with_cast": str(cast_id), "page": page}
81+
tasks.append(self._fetch_discover_page(mtype, discover_params, cast_id, "cast"))
82+
83+
# Execute all tasks in parallel
84+
results = await asyncio.gather(*tasks, return_exceptions=True)
85+
86+
# Collect results
87+
for result in results:
88+
if isinstance(result, Exception):
89+
continue
90+
for item in result:
91+
item_id = item.get("id")
92+
if item_id:
93+
all_candidates[item_id] = item
14894

14995
# Filter candidates
15096
excluded_ids = RecommendationFiltering.get_excluded_genre_ids(self.user_settings, content_type)
@@ -174,67 +120,12 @@ async def get_recommendations_from_creators(
174120

175121
return final[:limit]
176122

177-
async def _count_creator_frequencies(
178-
self, library_items: dict[str, list[dict[str, Any]]], content_type: str
179-
) -> tuple[dict[int, int], dict[int, int]]:
180-
"""
181-
Count raw frequencies of directors and cast in library items.
182-
183-
Args:
184-
library_items: Library items dict
185-
content_type: Content type
186-
187-
Returns:
188-
Tuple of (director_frequencies, cast_frequencies)
189-
"""
190-
director_frequencies = defaultdict(int)
191-
cast_frequencies = defaultdict(int)
192-
193-
all_items = (
194-
library_items.get("loved", [])
195-
+ library_items.get("liked", [])
196-
+ library_items.get("watched", [])
197-
+ library_items.get("added", [])
198-
)
199-
typed_items = [it for it in all_items if it.get("type") == content_type]
200-
201-
async def count_creators(item: dict):
202-
try:
203-
# Resolve TMDB ID
204-
item_id = item.get("_id", "")
205-
tmdb_id = await resolve_tmdb_id(item_id, self.tmdb_service)
206-
207-
if not tmdb_id:
208-
return
209-
210-
# Fetch metadata
211-
if content_type == "movie":
212-
meta = await self.tmdb_service.get_movie_details(tmdb_id)
213-
else:
214-
meta = await self.tmdb_service.get_tv_details(tmdb_id)
215-
216-
if not meta:
217-
return
218-
219-
credits = meta.get("credits") or {}
220-
crew = credits.get("crew") or []
221-
cast = credits.get("cast") or []
222-
223-
# Count directors
224-
for c in crew:
225-
if isinstance(c, dict) and c.get("job") == "Director":
226-
dir_id = c.get("id")
227-
if dir_id:
228-
director_frequencies[dir_id] += 1
229-
230-
# Count cast (top 5 only)
231-
for c in cast[:5]:
232-
if isinstance(c, dict) and c.get("id"):
233-
cast_frequencies[c.get("id")] += 1
234-
except Exception:
235-
pass
236-
237-
# Count frequencies in parallel
238-
await asyncio.gather(*[count_creators(item) for item in typed_items], return_exceptions=True)
239-
240-
return director_frequencies, cast_frequencies
123+
async def _fetch_discover_page(
124+
self, mtype: str, discover_params: dict[str, Any], creator_id: int, creator_type: str
125+
) -> list[dict[str, Any]]:
126+
try:
127+
results = await self.tmdb_service.get_discover(mtype, **discover_params)
128+
return results.get("results", [])
129+
except Exception as e:
130+
logger.debug(f"Error fetching recommendations for {creator_type} {creator_id}: {e}")
131+
return []

app/services/recommendation/theme_based.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
1-
"""
2-
Theme-Based Recommendations Service.
3-
4-
Fetches from TMDB discover API, scores with profile, applies quality filters.
5-
"""
6-
71
import asyncio
82
from typing import Any
93

104
from loguru import logger
115

6+
from app.core.constants import DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_MOVIE, DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_TV
127
from app.models.taste_profile import TasteProfile
138
from app.services.profile.scorer import ProfileScorer
149
from app.services.recommendation.filtering import RecommendationFiltering
@@ -30,13 +25,6 @@ class ThemeBasedService:
3025
"""
3126

3227
def __init__(self, tmdb_service: Any, user_settings: Any = None):
33-
"""
34-
Initialize theme-based service.
35-
36-
Args:
37-
tmdb_service: TMDB service for API calls
38-
user_settings: User settings for exclusions
39-
"""
4028
self.tmdb_service = tmdb_service
4129
self.user_settings = user_settings
4230
self.scorer = ProfileScorer()
@@ -114,10 +102,15 @@ async def get_recommendations_for_theme(
114102
try:
115103
profile_score = self.scorer.score_item(item, profile)
116104
# Add quality score
105+
minimum_rating = (
106+
DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_TV
107+
if content_type in ("tv", "series")
108+
else DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_MOVIE
109+
)
117110
wr = RecommendationScoring.weighted_rating(
118111
item.get("vote_average"),
119112
item.get("vote_count"),
120-
C=7.2 if content_type in ("tv", "series") else 6.8,
113+
C=minimum_rating,
121114
)
122115
quality_score = RecommendationScoring.normalize(wr)
123116

@@ -151,7 +144,7 @@ async def get_recommendations_for_theme(
151144
# Final filter (remove watched by IMDB ID)
152145
final = filter_watched_by_imdb(enriched, watched_imdb)
153146

154-
return final[:limit]
147+
return final
155148

156149
def _parse_theme_id(self, theme_id: str, content_type: str) -> dict[str, Any]:
157150
"""

0 commit comments

Comments
 (0)