From b9af14cd6e683d9a0d6100a038ea42e7b66abefb Mon Sep 17 00:00:00 2001 From: Bimal Timilsina Date: Sat, 27 Dec 2025 16:35:18 +0545 Subject: [PATCH 1/5] feat: add loved and liked all recommendation catalogs --- app/api/endpoints/manifest.py | 30 +-- app/core/settings.py | 36 +++- app/services/catalog.py | 19 ++ app/services/catalog_updater.py | 14 -- app/services/recommendation/all_based.py | 181 ++++++++++++++++++ .../recommendation/catalog_service.py | 71 +++++-- app/static/script.js | 5 +- 7 files changed, 295 insertions(+), 61 deletions(-) create mode 100644 app/services/recommendation/all_based.py diff --git a/app/api/endpoints/manifest.py b/app/api/endpoints/manifest.py index 2534368..9fbf34b 100644 --- a/app/api/endpoints/manifest.py +++ b/app/api/endpoints/manifest.py @@ -1,34 +1,22 @@ from datetime import datetime, timezone -from fastapi import HTTPException, Response +from fastapi import HTTPException from fastapi.routing import APIRouter from loguru import logger from app.core.config import settings -from app.core.settings import UserSettings, get_default_settings +from app.core.settings import UserSettings from app.core.version import __version__ from app.services.catalog import DynamicCatalogService from app.services.catalog_updater import get_config_id from app.services.stremio.service import StremioBundle from app.services.token_store import token_store from app.services.translation import translation_service -from app.utils.catalog import get_catalogs_from_config router = APIRouter() -def get_base_manifest(user_settings: UserSettings | None = None): - catalogs = [] - - if user_settings: - catalogs.extend(get_catalogs_from_config(user_settings, "watchly.rec", "Top Picks for You", True, True)) - catalogs.extend( - get_catalogs_from_config(user_settings, "watchly.creators", "From your favourite Creators", False, False) - ) - else: - # Default: empty catalogs - catalogs = [] - +def get_base_manifest(): return { "id": settings.ADDON_ID, "version": __version__, @@ -42,7 +30,7 @@ def get_base_manifest(user_settings: UserSettings | None = None): "resources": ["catalog"], "types": ["movie", "series"], "idPrefixes": ["tt"], - "catalogs": catalogs, + "catalogs": [], "behaviorHints": {"configurable": True, "configurationRequired": False}, "stremioAddonsConfig": { "issuer": "https://stremio-addons.net", @@ -62,7 +50,7 @@ async def build_dynamic_catalogs(bundle: StremioBundle, auth_key: str, user_sett return await dynamic_catalog_service.get_dynamic_catalogs(library_items, user_settings) -async def _manifest_handler(response: Response, token: str): +async def _manifest_handler(token: str): # response.headers["Cache-Control"] = "public, max-age=300" # 5 minutes if not token: raise HTTPException(status_code=401, detail="Missing token. Please reconfigure the addon.") @@ -79,7 +67,7 @@ async def _manifest_handler(response: Response, token: str): if not creds: raise HTTPException(status_code=401, detail="Token not found. Please reconfigure the addon.") - base_manifest = get_base_manifest(user_settings) + base_manifest = get_base_manifest() bundle = StremioBundle() fetched_catalogs = [] @@ -111,7 +99,7 @@ async def _manifest_handler(response: Response, token: str): fetched_catalogs = await build_dynamic_catalogs( bundle, auth_key, - user_settings or get_default_settings(), + user_settings, ) except Exception as e: logger.exception(f"[{token}] Dynamic catalog build failed: {e}") @@ -154,5 +142,5 @@ async def manifest(): @router.get("/{token}/manifest.json") -async def manifest_token(response: Response, token: str): - return await _manifest_handler(response, token) +async def manifest_token(token: str): + return await _manifest_handler(token) diff --git a/app/core/settings.py b/app/core/settings.py index e0a6e29..76fd84e 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -24,11 +24,25 @@ def get_default_settings() -> UserSettings: language="en-US", catalogs=[ CatalogConfig( - id="watchly.rec", name="Top Picks for You", enabled=True, enabled_movie=True, enabled_series=True + id="watchly.rec", + name="Top Picks for You", + enabled=True, + enabled_movie=True, + enabled_series=True, + ), + CatalogConfig( + id="watchly.loved", + name="More Like", + enabled=True, + enabled_movie=True, + enabled_series=True, ), - CatalogConfig(id="watchly.loved", name="More Like", enabled=True, enabled_movie=True, enabled_series=True), CatalogConfig( - id="watchly.watched", name="Because you watched", enabled=True, enabled_movie=True, enabled_series=True + id="watchly.watched", + name="Because you watched", + enabled=True, + enabled_movie=True, + enabled_series=True, ), CatalogConfig( id="watchly.theme", @@ -40,7 +54,21 @@ def get_default_settings() -> UserSettings: CatalogConfig( id="watchly.creators", name="From your favourite Creators", - enabled=True, + enabled=False, + enabled_movie=True, + enabled_series=True, + ), + CatalogConfig( + id="watchly.all.loved", + name="Based on what you loved", + enabled=False, + enabled_movie=True, + enabled_series=True, + ), + CatalogConfig( + id="watchly.liked.all", + name="Based on what you liked", + enabled=False, enabled_movie=True, enabled_series=True, ), diff --git a/app/services/catalog.py b/app/services/catalog.py index 8722539..61096e8 100644 --- a/app/services/catalog.py +++ b/app/services/catalog.py @@ -10,6 +10,7 @@ from app.services.row_generator import RowGeneratorService from app.services.scoring import ScoringService from app.services.tmdb.service import get_tmdb_service +from app.utils.catalog import get_catalogs_from_config class DynamicCatalogService: @@ -181,6 +182,24 @@ async def get_dynamic_catalogs(self, library_items: dict, user_settings: UserSet for mtype in ["movie", "series"]: await self._add_item_based_rows(catalogs, library_items, mtype, loved_cfg, watched_cfg) + # 4. Add watchly.rec catalog + catalogs.extend(get_catalogs_from_config(user_settings, "watchly.rec", "Top Picks for You", True, True)) + + # 5. Add watchly.creators catalog + catalogs.extend( + get_catalogs_from_config(user_settings, "watchly.creators", "From your favourite Creators", False, False) + ) + + # 6. Add watchly.all.loved catalog + catalogs.extend( + get_catalogs_from_config(user_settings, "watchly.all.loved", "Based on what you loved", True, True) + ) + + # 7. Add watchly.liked.all catalog + catalogs.extend( + get_catalogs_from_config(user_settings, "watchly.liked.all", "Based on what you liked", True, True) + ) + return catalogs def _resolve_catalog_configs(self, user_settings: UserSettings) -> tuple[Any, Any, Any]: diff --git a/app/services/catalog_updater.py b/app/services/catalog_updater.py index 282a808..7fc9afa 100644 --- a/app/services/catalog_updater.py +++ b/app/services/catalog_updater.py @@ -12,7 +12,6 @@ from app.services.stremio.service import StremioBundle from app.services.token_store import token_store from app.services.translation import translation_service -from app.utils.catalog import get_catalogs_from_config def get_config_id(catalog) -> str | None: @@ -23,10 +22,6 @@ def get_config_id(catalog) -> str | None: return "watchly.loved" if catalog_id.startswith("watchly.watched."): return "watchly.watched" - if catalog_id.startswith("watchly.item."): - return "watchly.item" - if catalog_id.startswith("watchly.rec"): - return "watchly.rec" return catalog_id @@ -134,15 +129,6 @@ async def refresh_catalogs_for_credentials( library_items=library_items, user_settings=user_settings ) - # now add the default catalogs - if user_settings: - catalogs.extend(get_catalogs_from_config(user_settings, "watchly.rec", "Top Picks for You", True, True)) - catalogs.extend( - get_catalogs_from_config( - user_settings, "watchly.creators", "From your favourite Creators", False, False - ) - ) - # Translate catalogs if user_settings and user_settings.language: for cat in catalogs: diff --git a/app/services/recommendation/all_based.py b/app/services/recommendation/all_based.py new file mode 100644 index 0000000..2ca2150 --- /dev/null +++ b/app/services/recommendation/all_based.py @@ -0,0 +1,181 @@ +import asyncio +from typing import Any + +from loguru import logger + +from app.core.settings import UserSettings +from app.models.taste_profile import TasteProfile +from app.services.profile.scorer import ProfileScorer +from app.services.recommendation.filtering import RecommendationFiltering +from app.services.recommendation.metadata import RecommendationMetadata +from app.services.recommendation.scoring import RecommendationScoring +from app.services.recommendation.utils import ( + content_type_to_mtype, + filter_by_genres, + filter_watched_by_imdb, + resolve_tmdb_id, +) +from app.services.tmdb.service import TMDBService + + +class AllBasedService: + """ + Handles recommendations based on all loved or all liked items. + """ + + def __init__(self, tmdb_service: TMDBService, user_settings: UserSettings | None = None): + self.tmdb_service = tmdb_service + self.user_settings = user_settings + self.scorer = ProfileScorer() + + async def get_recommendations_from_all_items( + self, + library_items: dict[str, list[dict[str, Any]]], + content_type: str, + watched_tmdb: set[int], + watched_imdb: set[str], + whitelist: set[int] | None = None, + limit: int = 20, + item_type: str = "loved", # "loved" or "liked" + profile: TasteProfile | None = None, + ) -> list[dict[str, Any]]: + """ + Get recommendations based on all loved or liked items. + + Strategy: + 1. Get all loved/liked items for the content type + 2. Fetch recommendations for each item (limit to top 10 items to avoid too many API calls) + 3. Combine and deduplicate recommendations + 4. Filter by genres and watched items + 5. Return top N + + Args: + library_items: Library items dict + content_type: Content type (movie/series) + watched_tmdb: Set of watched TMDB IDs + watched_imdb: Set of watched IMDB IDs + whitelist: Genre whitelist + limit: Number of items to return + item_type: "loved" or "liked" + profile: Optional profile for scoring (if None, uses popularity only) + + Returns: + List of recommended items + """ + # Get all loved or liked items for the content type + items = library_items.get(item_type, []) + typed_items = [it for it in items if it.get("type") == content_type] + + if not typed_items or len(typed_items) == 0: + return [] + + # Limit to top 10 items to avoid too many API calls + # We'll process them in parallel + top_items = typed_items[:10] + + mtype = content_type_to_mtype(content_type) + + # Fetch recommendations for each item in parallel + all_candidates = {} + tasks = [] + + for item in top_items: + item_id = item.get("_id", "") + if not item_id: + continue + + # Resolve TMDB ID and fetch recommendations + tasks.append(self._fetch_recommendations_for_item(item_id, mtype)) + + # Execute all in parallel + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Combine all recommendations (deduplicate by TMDB ID) + for res in results: + if isinstance(res, Exception): + logger.debug(f"Error fetching recommendations: {res}") + continue + for candidate in res: + candidate_id = candidate.get("id") + if candidate_id: + all_candidates[candidate_id] = candidate + + # Convert to list + candidates = list(all_candidates.values()) + + # Filter by genres and watched items + excluded_ids = RecommendationFiltering.get_excluded_genre_ids(self.user_settings, content_type) + whitelist = whitelist or set() + filtered = filter_by_genres(candidates, watched_tmdb, whitelist, excluded_ids) + + # Score with profile if available + if profile: + scored = [] + mtype = content_type_to_mtype(content_type) + for item in filtered: + try: + final_score = RecommendationScoring.calculate_final_score( + item=item, + profile=profile, + scorer=self.scorer, + mtype=mtype, + is_ranked=False, + is_fresh=False, + ) + + # Apply genre multiplier (if whitelist available) + genre_mult = RecommendationFiltering.get_genre_multiplier(item.get("genre_ids"), whitelist) + final_score *= genre_mult + + scored.append((final_score, item)) + except Exception as e: + logger.debug(f"Failed to score item {item.get('id')}: {e}") + continue + + # Sort by score + scored.sort(key=lambda x: x[0], reverse=True) + filtered = [item for _, item in scored] + + # Enrich metadata + enriched = await RecommendationMetadata.fetch_batch( + self.tmdb_service, filtered, content_type, user_settings=self.user_settings + ) + + # Final filter (remove watched by IMDB ID) + final = filter_watched_by_imdb(enriched, watched_imdb) + + # Return top N + return final[:limit] + + async def _fetch_recommendations_for_item(self, item_id: str, mtype: str) -> list[dict[str, Any]]: + """ + Fetch recommendations for a single item. + + Args: + item_id: Item ID (tt... or tmdb:...) + mtype: Media type (movie/tv) + + Returns: + List of candidate items + """ + # Resolve TMDB ID + tmdb_id = await resolve_tmdb_id(item_id, self.tmdb_service) + if not tmdb_id: + return [] + + combined = {} + + # Fetch 1 page each for recommendations + for action in ["recommendations"]: + method = getattr(self.tmdb_service, f"get_{action}") + try: + res = await method(tmdb_id, mtype, page=1) + for item in res.get("results", []): + item_id = item.get("id") + if item_id: + combined[item_id] = item + except Exception as e: + logger.debug(f"Error fetching {action} for {tmdb_id}: {e}") + continue + + return list(combined.values()) diff --git a/app/services/recommendation/catalog_service.py b/app/services/recommendation/catalog_service.py index cb08750..b0808eb 100644 --- a/app/services/recommendation/catalog_service.py +++ b/app/services/recommendation/catalog_service.py @@ -11,11 +11,11 @@ from app.models.taste_profile import TasteProfile from app.services.catalog_updater import catalog_updater from app.services.profile.integration import ProfileIntegration +from app.services.recommendation.all_based import AllBasedService from app.services.recommendation.creators import CreatorsService from app.services.recommendation.item_based import ItemBasedService from app.services.recommendation.theme_based import ThemeBasedService from app.services.recommendation.top_picks import TopPicksService -from app.services.recommendation.utils import pad_to_min from app.services.stremio.service import StremioBundle from app.services.tmdb.service import get_tmdb_service from app.services.token_store import token_store @@ -98,16 +98,16 @@ async def get_catalog( ) # Pad if needed - if len(recommendations) < min_items: - recommendations = await pad_to_min( - content_type, - recommendations, - min_items, - services["tmdb"], - user_settings, - watched_tmdb, - watched_imdb, - ) + # if len(recommendations) < min_items: + # recommendations = await pad_to_min( + # content_type, + # recommendations, + # min_items, + # services["tmdb"], + # user_settings, + # watched_tmdb, + # watched_imdb, + # ) logger.info(f"Returning {len(recommendations)} items for {content_type}") @@ -131,18 +131,16 @@ def _validate_inputs(self, token: str, content_type: str, catalog_id: str) -> No raise HTTPException(status_code=400, detail="Invalid type. Use 'movie' or 'series'") # Supported IDs - if catalog_id not in ["watchly.rec", "watchly.creators"] and not any( - catalog_id.startswith(p) - for p in ( - "watchly.theme.", - "watchly.loved.", - "watchly.watched.", - ) - ): + supported_base = ["watchly.rec", "watchly.creators", "watchly.all.loved", "watchly.liked.all"] + supported_prefixes = ("watchly.theme.", "watchly.loved.", "watchly.watched.") + if catalog_id not in supported_base and not any(catalog_id.startswith(p) for p in supported_prefixes): logger.warning(f"Invalid id: {catalog_id}") raise HTTPException( status_code=400, - detail=("Invalid id. Supported: 'watchly.rec', 'watchly.creators', 'watchly.theme.'"), + detail=( + "Invalid id. Supported: 'watchly.rec', 'watchly.creators', " + "'watchly.theme.', 'watchly.all.loved', 'watchly.liked.all'" + ), ) async def _resolve_auth(self, bundle: StremioBundle, credentials: dict, token: str) -> str: @@ -189,6 +187,7 @@ def _initialize_services(self, language: str, user_settings: UserSettings) -> di "theme": ThemeBasedService(tmdb_service, user_settings), "top_picks": TopPicksService(tmdb_service, user_settings), "creators": CreatorsService(tmdb_service, user_settings), + "all_based": AllBasedService(tmdb_service, user_settings), } def _get_catalog_limits(self, catalog_id: str, user_settings: UserSettings) -> tuple[int, int]: @@ -213,7 +212,7 @@ def _get_catalog_limits(self, catalog_id: str, user_settings: UserSettings) -> t max_items = max(min_items, min(DEFAULT_MAX_ITEMS, int(max_items))) except (ValueError, TypeError): logger.warning( - f"Invalid min/max items values. Falling back to defaults. " + "Invalid min/max items values. Falling back to defaults. " f"min_items={min_items}, max_items={max_items}" ) min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS @@ -304,6 +303,36 @@ async def _get_recommendations( recommendations = [] logger.info(f"Found {len(recommendations)} top picks for {content_type}") + # Based on what you loved + elif catalog_id == "watchly.all.loved": + all_based_service: AllBasedService = services["all_based"] + recommendations = await all_based_service.get_recommendations_from_all_items( + library_items=library_items, + content_type=content_type, + watched_tmdb=watched_tmdb, + watched_imdb=watched_imdb, + whitelist=whitelist, + limit=max_items, + item_type="loved", + profile=profile, + ) + logger.info(f"Found {len(recommendations)} recommendations based on all loved items") + + # Based on what you liked + elif catalog_id == "watchly.liked.all": + all_based_service: AllBasedService = services["all_based"] + recommendations = await all_based_service.get_recommendations_from_all_items( + library_items=library_items, + content_type=content_type, + watched_tmdb=watched_tmdb, + watched_imdb=watched_imdb, + whitelist=whitelist, + limit=max_items, + item_type="liked", + profile=profile, + ) + logger.info(f"Found {len(recommendations)} recommendations based on all liked items") + else: logger.warning(f"Unknown catalog ID: {catalog_id}") recommendations = [] diff --git a/app/static/script.js b/app/static/script.js index 31616db..d0b63f7 100644 --- a/app/static/script.js +++ b/app/static/script.js @@ -4,7 +4,9 @@ const defaultCatalogs = [ { id: 'watchly.loved', name: 'More Like', enabled: true, enabledMovie: true, enabledSeries: true, minItems: 20, maxItems: 24, description: 'Recommendations similar to content you explicitly loved' }, { id: 'watchly.watched', name: 'Because You Watched', enabled: true, enabledMovie: true, enabledSeries: true, minItems: 20, maxItems: 24, description: 'Recommendations based on your recent watch history' }, { id: 'watchly.theme', name: 'Genre & Keyword Catalogs', enabled: true, enabledMovie: true, enabledSeries: true, minItems: 20, maxItems: 24, description: 'Dynamic catalogs based on your favorite genres, keyword, countries and many more. Just like netflix. Example: American Horror, Based on Novel or Book etc.' }, - { id: 'watchly.creators', name: 'From your favourite Creators', enabled: true, enabledMovie: true, enabledSeries: true, minItems: 20, maxItems: 24, description: 'Movies and series from your top 5 favorite directors and top 5 favorite actors' }, + { id: 'watchly.creators', name: 'From your favourite Creators', enabled: false, enabledMovie: true, enabledSeries: true, minItems: 20, maxItems: 24, description: 'Movies and series from your top 5 favorite directors and top 5 favorite actors' }, + { id: 'watchly.all.loved', name: 'Based on what you loved', enabled: false, enabledMovie: true, enabledSeries: true, minItems: 20, maxItems: 24, description: 'Recommendations based on all your loved items' }, + { id: 'watchly.liked.all', name: 'Based on what you liked', enabled: false, enabledMovie: true, enabledSeries: true, minItems: 20, maxItems: 24, description: 'Recommendations based on all your liked items' }, ]; let catalogs = JSON.parse(JSON.stringify(defaultCatalogs)); @@ -371,6 +373,7 @@ async function fetchStremioIdentity(authKey) { }); renderCatalogList(); } + } // Update UI for "Update Mode" From b3ed5ba99bd22f5ec4e5385d5a81695430f99bc7 Mon Sep 17 00:00:00 2001 From: Bimal Timilsina Date: Sat, 27 Dec 2025 16:35:53 +0545 Subject: [PATCH 2/5] chore: bump version to v1.5.0 --- app/core/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/version.py b/app/core/version.py index 56dadec..5b60188 100644 --- a/app/core/version.py +++ b/app/core/version.py @@ -1 +1 @@ -__version__ = "1.4.5" +__version__ = "1.5.0" From 2bb584ab3a2b4edfc86602a6dab40659759c8aba Mon Sep 17 00:00:00 2001 From: Bimal Timilsina Date: Sat, 27 Dec 2025 16:47:31 +0545 Subject: [PATCH 3/5] feat: enhance dynamic catalog building and improve error handling --- app/api/endpoints/manifest.py | 8 +++- app/services/recommendation/all_based.py | 19 ++++---- .../recommendation/catalog_service.py | 44 +++++++------------ 3 files changed, 31 insertions(+), 40 deletions(-) diff --git a/app/api/endpoints/manifest.py b/app/api/endpoints/manifest.py index 9fbf34b..cffbc2f 100644 --- a/app/api/endpoints/manifest.py +++ b/app/api/endpoints/manifest.py @@ -41,8 +41,14 @@ def get_base_manifest(): } -async def build_dynamic_catalogs(bundle: StremioBundle, auth_key: str, user_settings: UserSettings) -> list[dict]: +async def build_dynamic_catalogs( + bundle: StremioBundle, auth_key: str, user_settings: UserSettings | None +) -> list[dict]: # Fetch library using bundle directly + if not user_settings: + logger.error("User settings not found. Please reconfigure the addon.") + raise HTTPException(status_code=401, detail="User settings not found. Please reconfigure the addon.") + library_items = await bundle.library.get_library_items(auth_key) dynamic_catalog_service = DynamicCatalogService( language=user_settings.language, diff --git a/app/services/recommendation/all_based.py b/app/services/recommendation/all_based.py index 2ca2150..d936d60 100644 --- a/app/services/recommendation/all_based.py +++ b/app/services/recommendation/all_based.py @@ -166,16 +166,13 @@ async def _fetch_recommendations_for_item(self, item_id: str, mtype: str) -> lis combined = {} # Fetch 1 page each for recommendations - for action in ["recommendations"]: - method = getattr(self.tmdb_service, f"get_{action}") - try: - res = await method(tmdb_id, mtype, page=1) - for item in res.get("results", []): - item_id = item.get("id") - if item_id: - combined[item_id] = item - except Exception as e: - logger.debug(f"Error fetching {action} for {tmdb_id}: {e}") - continue + try: + res = await self.tmdb_service.get_recommendations(tmdb_id, mtype, page=1) + for item in res.get("results", []): + item_id = item.get("id") + if item_id: + combined[item_id] = item + except Exception as e: + logger.debug(f"Error fetching recommendations for {tmdb_id}: {e}") return list(combined.values()) diff --git a/app/services/recommendation/catalog_service.py b/app/services/recommendation/catalog_service.py index b0808eb..e57b92b 100644 --- a/app/services/recommendation/catalog_service.py +++ b/app/services/recommendation/catalog_service.py @@ -16,6 +16,7 @@ from app.services.recommendation.item_based import ItemBasedService from app.services.recommendation.theme_based import ThemeBasedService from app.services.recommendation.top_picks import TopPicksService +from app.services.recommendation.utils import pad_to_min from app.services.stremio.service import StremioBundle from app.services.tmdb.service import get_tmdb_service from app.services.token_store import token_store @@ -98,16 +99,17 @@ async def get_catalog( ) # Pad if needed - # if len(recommendations) < min_items: - # recommendations = await pad_to_min( - # content_type, - # recommendations, - # min_items, - # services["tmdb"], - # user_settings, - # watched_tmdb, - # watched_imdb, - # ) + # TODO: This is risky because it can fetch too many unrelated items. + if recommendations and len(recommendations) < 8: + recommendations = await pad_to_min( + content_type, + recommendations, + 10, # only fetch 10 items if less than 8 + services["tmdb"], + user_settings, + watched_tmdb, + watched_imdb, + ) logger.info(f"Returning {len(recommendations)} items for {content_type}") @@ -304,22 +306,8 @@ async def _get_recommendations( logger.info(f"Found {len(recommendations)} top picks for {content_type}") # Based on what you loved - elif catalog_id == "watchly.all.loved": - all_based_service: AllBasedService = services["all_based"] - recommendations = await all_based_service.get_recommendations_from_all_items( - library_items=library_items, - content_type=content_type, - watched_tmdb=watched_tmdb, - watched_imdb=watched_imdb, - whitelist=whitelist, - limit=max_items, - item_type="loved", - profile=profile, - ) - logger.info(f"Found {len(recommendations)} recommendations based on all loved items") - - # Based on what you liked - elif catalog_id == "watchly.liked.all": + elif catalog_id in ("watchly.all.loved", "watchly.liked.all"): + item_type = "loved" if catalog_id == "watchly.all.loved" else "liked" all_based_service: AllBasedService = services["all_based"] recommendations = await all_based_service.get_recommendations_from_all_items( library_items=library_items, @@ -328,10 +316,10 @@ async def _get_recommendations( watched_imdb=watched_imdb, whitelist=whitelist, limit=max_items, - item_type="liked", + item_type=item_type, profile=profile, ) - logger.info(f"Found {len(recommendations)} recommendations based on all liked items") + logger.info(f"Found {len(recommendations)} recommendations based on all {item_type} items") else: logger.warning(f"Unknown catalog ID: {catalog_id}") From 441faf3240a450f5842362640d7c3f9b8f40bced Mon Sep 17 00:00:00 2001 From: Bimal Timilsina <45899783+TimilsinaBimal@users.noreply.github.com> Date: Sat, 27 Dec 2025 16:51:12 +0545 Subject: [PATCH 4/5] Update app/services/recommendation/all_based.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- app/services/recommendation/all_based.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/recommendation/all_based.py b/app/services/recommendation/all_based.py index d936d60..0ae5503 100644 --- a/app/services/recommendation/all_based.py +++ b/app/services/recommendation/all_based.py @@ -169,9 +169,9 @@ async def _fetch_recommendations_for_item(self, item_id: str, mtype: str) -> lis try: res = await self.tmdb_service.get_recommendations(tmdb_id, mtype, page=1) for item in res.get("results", []): - item_id = item.get("id") - if item_id: - combined[item_id] = item + candidate_id = item.get("id") + if candidate_id: + combined[candidate_id] = item except Exception as e: logger.debug(f"Error fetching recommendations for {tmdb_id}: {e}") From dc563f519198ec15a6cc1064df472c6cb1e8c304 Mon Sep 17 00:00:00 2001 From: Bimal Timilsina Date: Sat, 27 Dec 2025 16:53:33 +0545 Subject: [PATCH 5/5] refactor: introduce constants for item limits in recommendation services --- app/services/recommendation/all_based.py | 6 +++--- app/services/recommendation/catalog_service.py | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/services/recommendation/all_based.py b/app/services/recommendation/all_based.py index 0ae5503..9d10d2f 100644 --- a/app/services/recommendation/all_based.py +++ b/app/services/recommendation/all_based.py @@ -17,6 +17,8 @@ ) from app.services.tmdb.service import TMDBService +TOP_ITEMS_LIMIT = 10 + class AllBasedService: """ @@ -69,9 +71,8 @@ async def get_recommendations_from_all_items( if not typed_items or len(typed_items) == 0: return [] - # Limit to top 10 items to avoid too many API calls # We'll process them in parallel - top_items = typed_items[:10] + top_items = typed_items[:TOP_ITEMS_LIMIT] mtype = content_type_to_mtype(content_type) @@ -111,7 +112,6 @@ async def get_recommendations_from_all_items( # Score with profile if available if profile: scored = [] - mtype = content_type_to_mtype(content_type) for item in filtered: try: final_score = RecommendationScoring.calculate_final_score( diff --git a/app/services/recommendation/catalog_service.py b/app/services/recommendation/catalog_service.py index e57b92b..86177a2 100644 --- a/app/services/recommendation/catalog_service.py +++ b/app/services/recommendation/catalog_service.py @@ -21,6 +21,9 @@ from app.services.tmdb.service import get_tmdb_service from app.services.token_store import token_store +PAD_RECOMMENDATIONS_THRESHOLD = 8 +PAD_RECOMMENDATIONS_TARGET = 10 + class CatalogService: def __init__(self): @@ -100,11 +103,11 @@ async def get_catalog( # Pad if needed # TODO: This is risky because it can fetch too many unrelated items. - if recommendations and len(recommendations) < 8: + if recommendations and len(recommendations) < PAD_RECOMMENDATIONS_THRESHOLD: recommendations = await pad_to_min( content_type, recommendations, - 10, # only fetch 10 items if less than 8 + PAD_RECOMMENDATIONS_TARGET, services["tmdb"], user_settings, watched_tmdb,