Skip to content

Commit c7edd89

Browse files
feat: Add genre exclusion UI, store excluded genres in user settings,and apply them during catalog generation (#21)
* feat: Add genre exclusion UI, store excluded genres in user settings, and apply them during catalog generation. * refactor: simplify filtering recommendations by excluded genres using list comprehension * refactor: streamline genre exclusion logic for similarity recommendations
1 parent db1a62d commit c7edd89

File tree

8 files changed

+246
-22
lines changed

8 files changed

+246
-22
lines changed

app/api/endpoints/tokens.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class TokenRequest(BaseModel):
2121
catalogs: list[CatalogConfig] | None = Field(default=None, description="Optional catalog configuration")
2222
language: str = Field(default="en-US", description="Language for TMDB API")
2323
rpdb_key: str | None = Field(default=None, description="Optional RPDB API Key")
24+
excluded_movie_genres: list[str] = Field(default_factory=list, description="List of movie genre IDs to exclude")
25+
excluded_series_genres: list[str] = Field(default_factory=list, description="List of series genre IDs to exclude")
2426

2527

2628
class TokenResponse(BaseModel):
@@ -130,6 +132,8 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
130132
language=payload.language or default_settings.language,
131133
catalogs=payload.catalogs if payload.catalogs else default_settings.catalogs,
132134
rpdb_key=rpdb_key,
135+
excluded_movie_genres=payload.excluded_movie_genres,
136+
excluded_series_genres=payload.excluded_series_genres,
133137
)
134138

135139
# encode_settings now includes the "settings:" prefix

app/core/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class UserSettings(BaseModel):
1414
catalogs: list[CatalogConfig]
1515
language: str = "en-US"
1616
rpdb_key: str | None = None
17+
excluded_movie_genres: list[str] = []
18+
excluded_series_genres: list[str] = []
1719

1820

1921
def encode_settings(settings: UserSettings) -> str:

app/services/catalog.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ def build_catalog_entry(self, item, label, config_id):
4545
"extra": [],
4646
}
4747

48-
async def get_theme_based_catalogs(self, library_items: list[dict]) -> list[dict]:
48+
async def get_theme_based_catalogs(
49+
self, library_items: list[dict], user_settings: UserSettings | None = None
50+
) -> list[dict]:
4951
catalogs = []
5052
# 1. Build User Profile
5153
# Combine loved and watched
@@ -65,16 +67,27 @@ async def get_theme_based_catalogs(self, library_items: list[dict]) -> list[dict
6567
scored_obj = self.scoring_service.process_item(item_data)
6668
scored_objects.append(scored_obj)
6769

70+
# Get excluded genres
71+
excluded_movie_genres = []
72+
excluded_series_genres = []
73+
if user_settings:
74+
excluded_movie_genres = [int(g) for g in user_settings.excluded_movie_genres]
75+
excluded_series_genres = [int(g) for g in user_settings.excluded_series_genres]
76+
6877
# 2. Generate Thematic Rows with Type-Specific Profiles
6978
# Generate for Movies
70-
movie_profile = await self.user_profile_service.build_user_profile(scored_objects, content_type="movie")
79+
movie_profile = await self.user_profile_service.build_user_profile(
80+
scored_objects, content_type="movie", excluded_genres=excluded_movie_genres
81+
)
7182
movie_rows = await self.row_generator.generate_rows(movie_profile, "movie")
7283

7384
for row in movie_rows:
7485
catalogs.append({"type": "movie", "id": row.id, "name": row.title, "extra": []})
7586

7687
# Generate for Series
77-
series_profile = await self.user_profile_service.build_user_profile(scored_objects, content_type="series")
88+
series_profile = await self.user_profile_service.build_user_profile(
89+
scored_objects, content_type="series", excluded_genres=excluded_series_genres
90+
)
7891
series_rows = await self.row_generator.generate_rows(series_profile, "series")
7992

8093
for row in series_rows:
@@ -98,7 +111,7 @@ async def get_dynamic_catalogs(
98111
catalogs = []
99112

100113
if include_theme_based_rows:
101-
catalogs.extend(await self.get_theme_based_catalogs(library_items))
114+
catalogs.extend(await self.get_theme_based_catalogs(library_items, user_settings))
102115

103116
# 3. Add Item-Based Rows
104117
if include_item_based_rows:

app/services/discovery.py

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ def __init__(self):
1414
self.tmdb_service = TMDBService()
1515

1616
async def discover_recommendations(
17-
self, profile: UserTasteProfile, content_type: str, limit: int = 20
17+
self,
18+
profile: UserTasteProfile,
19+
content_type: str,
20+
limit: int = 20,
21+
excluded_genres: list[int] | None = None,
1822
) -> list[dict]:
1923
"""
2024
Find content that matches the user's taste profile.
@@ -33,52 +37,78 @@ async def discover_recommendations(
3337
top_crew = profile.get_top_crew(limit=1) # e.g. [(555, 1.0)] - Director
3438

3539
top_countries = profile.get_top_countries(limit=2)
40+
top_year = profile.get_top_year(limit=1)
3641

3742
if not top_genres and not top_keywords and not top_cast:
3843
# Fallback if profile is empty
3944
return []
4045

4146
tasks = []
47+
base_params = {}
48+
if excluded_genres:
49+
base_params["without_genres"] = "|".join([str(g) for g in excluded_genres])
4250

4351
# Query 1: Top Genres Mix
4452
if top_genres:
4553
genre_ids = "|".join([str(g[0]) for g in top_genres])
46-
params_popular = {"with_genres": genre_ids, "sort_by": "popularity.desc", "vote_count.gte": 100}
54+
params_popular = {
55+
"with_genres": genre_ids,
56+
"sort_by": "popularity.desc",
57+
"vote_count.gte": 500,
58+
**base_params,
59+
}
4760
tasks.append(self._fetch_discovery(content_type, params_popular))
4861

4962
# fetch atleast two pages of results
5063
for i in range(2):
5164
params_rating = {
5265
"with_genres": genre_ids,
5366
"sort_by": "ratings.desc",
54-
"vote_count.gte": 300,
67+
"vote_count.gte": 500,
5568
"page": i + 1,
69+
**base_params,
5670
}
5771
tasks.append(self._fetch_discovery(content_type, params_rating))
5872

5973
# Query 2: Top Keywords
6074
if top_keywords:
6175
keyword_ids = "|".join([str(k[0]) for k in top_keywords])
62-
params_keywords = {"with_keywords": keyword_ids, "sort_by": "popularity.desc"}
76+
params_keywords = {
77+
"with_keywords": keyword_ids,
78+
"sort_by": "popularity.desc",
79+
"vote_count.gte": 500,
80+
**base_params,
81+
}
6382
tasks.append(self._fetch_discovery(content_type, params_keywords))
6483

6584
# fetch atleast two pages of results
6685
for i in range(3):
6786
params_rating = {
6887
"with_keywords": keyword_ids,
6988
"sort_by": "ratings.desc",
70-
"vote_count.gte": 300,
89+
"vote_count.gte": 500,
7190
"page": i + 1,
91+
**base_params,
7292
}
7393
tasks.append(self._fetch_discovery(content_type, params_rating))
7494

7595
# Query 3: Top Actors
7696
for actor in top_cast:
7797
actor_id = actor[0]
78-
params_actor = {"with_cast": str(actor_id), "sort_by": "popularity.desc"}
98+
params_actor = {
99+
"with_cast": str(actor_id),
100+
"sort_by": "popularity.desc",
101+
"vote_count.gte": 500,
102+
**base_params,
103+
}
79104
tasks.append(self._fetch_discovery(content_type, params_actor))
80105

81-
params_rating = {"with_cast": str(actor_id), "sort_by": "ratings.desc", "vote_count.gte": 300}
106+
params_rating = {
107+
"with_cast": str(actor_id),
108+
"sort_by": "ratings.desc",
109+
"vote_count.gte": 500,
110+
**base_params,
111+
}
82112
tasks.append(self._fetch_discovery(content_type, params_rating))
83113

84114
# Query 4: Top Director
@@ -87,19 +117,47 @@ async def discover_recommendations(
87117
params_director = {
88118
"with_crew": str(director_id),
89119
"sort_by": "vote_average.desc", # Directors imply quality preference
120+
"vote_count.gte": 500,
121+
**base_params,
90122
}
91123
tasks.append(self._fetch_discovery(content_type, params_director))
92124

93-
params_rating = {"with_crew": str(director_id), "sort_by": "ratings.desc", "vote_count.gte": 300}
125+
params_rating = {
126+
"with_crew": str(director_id),
127+
"sort_by": "ratings.desc",
128+
"vote_count.gte": 500,
129+
**base_params,
130+
}
94131
tasks.append(self._fetch_discovery(content_type, params_rating))
95132

96133
# Query 5: Top Countries
97134
if top_countries:
98135
country_ids = "|".join([str(c[0]) for c in top_countries])
99-
params_country = {"with_origin_country": country_ids, "sort_by": "popularity.desc", "vote_count.gte": 100}
136+
params_country = {
137+
"with_origin_country": country_ids,
138+
"sort_by": "popularity.desc",
139+
"vote_count.gte": 100,
140+
**base_params,
141+
}
100142
tasks.append(self._fetch_discovery(content_type, params_country))
101143

102-
params_rating = {"with_origin_country": country_ids, "sort_by": "ratings.desc", "vote_count.gte": 300}
144+
params_rating = {
145+
"with_origin_country": country_ids,
146+
"sort_by": "ratings.desc",
147+
"vote_count.gte": 300,
148+
**base_params,
149+
}
150+
tasks.append(self._fetch_discovery(content_type, params_rating))
151+
152+
# query 6: Top year
153+
if top_year:
154+
year = top_year[0][0]
155+
params_rating = {
156+
"year": year,
157+
"sort_by": "ratings.desc",
158+
"vote_count.gte": 500,
159+
**base_params,
160+
}
103161
tasks.append(self._fetch_discovery(content_type, params_rating))
104162

105163
# 3. Execute Parallel Queries

app/services/recommendation_service.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,15 @@ async def get_recommendations_for_item(self, item_id: str) -> list[dict]:
254254
# 1. Filter by TMDB ID
255255
recommendations = await self._filter_candidates(recommendations, watched_imdb, watched_tmdb)
256256

257+
# 1.5 Filter by Excluded Genres
258+
# We need to detect content_type from item_id or media_type to know which exclusion list to use.
259+
# media_type is already resolved above.
260+
excluded_ids = set(self._get_excluded_genre_ids(media_type))
261+
if excluded_ids:
262+
recommendations = [
263+
item for item in recommendations if not excluded_ids.intersection(item.get("genre_ids") or [])
264+
]
265+
257266
# 2. Fetch Metadata (gets IMDB IDs)
258267
meta_items = await self._fetch_metadata_for_items(recommendations, media_type)
259268

@@ -278,6 +287,15 @@ async def get_recommendations_for_item(self, item_id: str) -> list[dict]:
278287
logger.info(f"Found {len(final_items)} valid recommendations for {item_id}")
279288
return final_items
280289

290+
def _get_excluded_genre_ids(self, content_type: str) -> list[int]:
291+
if not self.user_settings:
292+
return []
293+
if content_type == "movie":
294+
return [int(g) for g in self.user_settings.excluded_movie_genres]
295+
elif content_type in ["series", "tv"]:
296+
return [int(g) for g in self.user_settings.excluded_series_genres]
297+
return []
298+
281299
async def get_recommendations_for_theme(self, theme_id: str, content_type: str, limit: int = 20) -> list[dict]:
282300
"""
283301
Parse a dynamic theme ID and fetch recommendations.
@@ -315,6 +333,16 @@ async def get_recommendations_for_theme(self, theme_id: str, content_type: str,
315333
if "sort_by" not in params:
316334
params["sort_by"] = "popularity.desc"
317335

336+
# Apply Excluded Genres
337+
excluded_ids = self._get_excluded_genre_ids(content_type)
338+
if excluded_ids:
339+
# If with_genres is specified, we technically shouldn't exclude what is explicitly asked for?
340+
# But the user asked to "exclude those genres".
341+
# If I exclude them from "without_genres", TMDB might return 0 results if the theme IS that genre.
342+
# But RowGenerator safeguards against generating themes for excluded genres.
343+
# So this is safe for keyword/country rows.
344+
params["without_genres"] = "|".join(str(g) for g in excluded_ids)
345+
318346
# Fetch
319347
recommendations = await self.tmdb_service.get_discover(content_type, **params)
320348
candidates = recommendations.get("results", [])
@@ -407,15 +435,25 @@ async def get_recommendations(
407435
tasks_a.append(self._fetch_recommendations_from_tmdb(source.get("_id"), source.get("type"), limit=10))
408436
similarity_candidates = []
409437
similarity_recommendations = await asyncio.gather(*tasks_a, return_exceptions=True)
438+
439+
excluded_ids = set(self._get_excluded_genre_ids(content_type))
440+
410441
similarity_recommendations = [item for item in similarity_recommendations if not isinstance(item, Exception)]
411-
for item in similarity_recommendations:
412-
similarity_candidates.extend(item)
442+
for batch in similarity_recommendations:
443+
similarity_candidates.extend(
444+
item for item in batch if not excluded_ids.intersection(item.get("genre_ids") or [])
445+
)
413446

414447
# --- Candidate Set B: Profile-based Discovery ---
448+
# Extract excluded genres
449+
excluded_genres = list(excluded_ids) # Convert back to list for consistency
450+
415451
# Use typed profile based on content_type
416-
user_profile = await self.user_profile_service.build_user_profile(scored_objects, content_type=content_type)
452+
user_profile = await self.user_profile_service.build_user_profile(
453+
scored_objects, content_type=content_type, excluded_genres=excluded_genres
454+
)
417455
discovery_candidates = await self.discovery_engine.discover_recommendations(
418-
user_profile, content_type, limit=20
456+
user_profile, content_type, limit=20, excluded_genres=excluded_genres
419457
)
420458

421459
# --- Combine & Deduplicate ---

app/services/user_profile.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ def __init__(self):
3838
self.tmdb_service = TMDBService()
3939

4040
async def build_user_profile(
41-
self, scored_items: list[ScoredItem], content_type: str | None = None
41+
self,
42+
scored_items: list[ScoredItem],
43+
content_type: str | None = None,
44+
excluded_genres: list[int] | None = None,
4245
) -> UserTasteProfile:
4346
"""
4447
Aggregates multiple item vectors into a single User Taste Profile.
@@ -76,7 +79,7 @@ async def build_user_profile(
7679
# Scale by Interest Score (0.0 - 1.0)
7780
interest_weight = item.score / 100.0
7881

79-
self._merge_vector(profile_data, item_vector, interest_weight)
82+
self._merge_vector(profile_data, item_vector, interest_weight, excluded_genres)
8083

8184
# Convert to Pydantic Model
8285
profile = UserTasteProfile(
@@ -206,7 +209,13 @@ def _vectorize_item(self, meta: dict) -> dict[str, list[int] | int | list[str] |
206209

207210
return vector
208211

209-
def _merge_vector(self, profile: dict, item_vector: dict, weight: float):
212+
def _merge_vector(
213+
self,
214+
profile: dict,
215+
item_vector: dict,
216+
weight: float,
217+
excluded_genres: list[int] | None = None,
218+
):
210219
"""Merges an item's sparse vector into the main profile with a weight."""
211220

212221
# Weights for specific dimensions (Feature Importance)
@@ -228,6 +237,8 @@ def _merge_vector(self, profile: dict, item_vector: dict, weight: float):
228237
profile["years"][ids] += final_weight
229238
elif ids:
230239
for feature_id in ids:
240+
if dim == "genres" and excluded_genres and feature_id in excluded_genres:
241+
continue
231242
profile[dim][feature_id] += final_weight
232243

233244
async def _fetch_full_metadata(self, tmdb_id: int, type_: str) -> dict | None:

static/index.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,31 @@
208208

209209
<div class="border-t border-slate-800"></div>
210210

211+
<!-- Genre Exclusion -->
212+
<div class="space-y-6">
213+
<label class="block text-sm font-medium text-slate-400 uppercase tracking-wider">Exclude Genres</label>
214+
215+
<div class="grid md:grid-cols-2 gap-6">
216+
<!-- Movie Genres -->
217+
<div class="space-y-3">
218+
<label class="block text-xs font-semibold text-slate-500 uppercase">Movies</label>
219+
<div id="movieGenreList" class="h-48 overflow-y-auto pr-2 space-y-2 custom-scrollbar bg-slate-800/50 rounded-xl p-3 border border-slate-700/50">
220+
<!-- Populated by JS -->
221+
</div>
222+
</div>
223+
224+
<!-- Series Genres -->
225+
<div class="space-y-3">
226+
<label class="block text-xs font-semibold text-slate-500 uppercase">Series</label>
227+
<div id="seriesGenreList" class="h-48 overflow-y-auto pr-2 space-y-2 custom-scrollbar bg-slate-800/50 rounded-xl p-3 border border-slate-700/50">
228+
<!-- Populated by JS -->
229+
</div>
230+
</div>
231+
</div>
232+
</div>
233+
234+
<div class="border-t border-slate-800"></div>
235+
211236
<!-- RPDB API Key -->
212237
<div class="space-y-4">
213238
<label class="block text-sm font-medium text-slate-400 uppercase tracking-wider">RPDB API Key

0 commit comments

Comments
 (0)