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-
81import asyncio
9- from collections import defaultdict
102from typing import Any
113
4+ from fastapi import HTTPException
125from loguru import logger
136
7+ from app .core .settings import UserSettings
148from app .models .taste_profile import TasteProfile
159from app .services .recommendation .filtering import RecommendationFiltering
1610from 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
2015class 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 []
0 commit comments