From b9af14cd6e683d9a0d6100a038ea42e7b66abefb Mon Sep 17 00:00:00 2001 From: Bimal Timilsina Date: Sat, 27 Dec 2025 16:35:18 +0545 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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, From 71c285fef3c7575d95af62ab7a065427eca9d87b Mon Sep 17 00:00:00 2001 From: Bimal Timilsina Date: Sat, 27 Dec 2025 17:34:11 +0545 Subject: [PATCH 06/11] refactor: remove unused min max items functionalities --- app/core/constants.py | 4 +- app/core/settings.py | 2 - .../recommendation/catalog_service.py | 60 ++++--------------- app/static/script.js | 26 +++----- 4 files changed, 21 insertions(+), 71 deletions(-) diff --git a/app/core/constants.py b/app/core/constants.py index e844b61..aec4c4f 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -1,6 +1,6 @@ RECOMMENDATIONS_CATALOG_NAME: str = "Top Picks For You" -DEFAULT_MIN_ITEMS: int = 20 -DEFAULT_MAX_ITEMS: int = 32 +DEFAULT_MIN_ITEMS: int = 8 +DEFAULT_CATALOG_LIMIT = 20 DEFAULT_CONCURRENCY_LIMIT: int = 30 diff --git a/app/core/settings.py b/app/core/settings.py index 76fd84e..c1ca9b6 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -7,8 +7,6 @@ class CatalogConfig(BaseModel): enabled: bool = True enabled_movie: bool = Field(default=True, description="Enable movie catalog for this configuration") enabled_series: bool = Field(default=True, description="Enable series catalog for this configuration") - min_items: int = Field(default=20, ge=1, le=20) - max_items: int = Field(default=24, ge=1, le=32) class UserSettings(BaseModel): diff --git a/app/services/recommendation/catalog_service.py b/app/services/recommendation/catalog_service.py index 86177a2..2985357 100644 --- a/app/services/recommendation/catalog_service.py +++ b/app/services/recommendation/catalog_service.py @@ -4,9 +4,8 @@ from fastapi import HTTPException from loguru import logger -from app.api.endpoints.manifest import get_config_id from app.core.config import settings -from app.core.constants import DEFAULT_MAX_ITEMS, DEFAULT_MIN_ITEMS +from app.core.constants import DEFAULT_CATALOG_LIMIT, DEFAULT_MIN_ITEMS from app.core.settings import UserSettings, get_default_settings from app.models.taste_profile import TasteProfile from app.services.catalog_updater import catalog_updater @@ -21,9 +20,6 @@ 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): @@ -85,9 +81,6 @@ async def get_catalog( ) whitelist = await integration_service.get_genre_whitelist(profile, content_type) if profile else set() - # Get catalog limits - min_items, max_items = self._get_catalog_limits(catalog_id, user_settings) - # Route to appropriate recommendation service recommendations = await self._get_recommendations( catalog_id=catalog_id, @@ -98,16 +91,16 @@ async def get_catalog( watched_imdb=watched_imdb, whitelist=whitelist, library_items=library_items, - max_items=max_items, + limit=DEFAULT_CATALOG_LIMIT, ) - # Pad if needed - # TODO: This is risky because it can fetch too many unrelated items. - if recommendations and len(recommendations) < PAD_RECOMMENDATIONS_THRESHOLD: + # Pad if needed to meet minimum of 8 items + # # TODO: This is risky because it can fetch too many unrelated items. + if recommendations and len(recommendations) < DEFAULT_MIN_ITEMS: recommendations = await pad_to_min( content_type, recommendations, - PAD_RECOMMENDATIONS_TARGET, + DEFAULT_MIN_ITEMS, services["tmdb"], user_settings, watched_tmdb, @@ -195,35 +188,6 @@ def _initialize_services(self, language: str, user_settings: UserSettings) -> di "all_based": AllBasedService(tmdb_service, user_settings), } - def _get_catalog_limits(self, catalog_id: str, user_settings: UserSettings) -> tuple[int, int]: - try: - cfg_id = get_config_id({"id": catalog_id}) - except Exception: - cfg_id = catalog_id - - try: - cfg = next((c for c in user_settings.catalogs if c.id == cfg_id), None) - if cfg and hasattr(cfg, "min_items") and hasattr(cfg, "max_items"): - min_items = int(cfg.min_items or DEFAULT_MIN_ITEMS) - max_items = int(cfg.max_items or DEFAULT_MAX_ITEMS) - else: - min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS - except Exception: - min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS - - # Enforce caps - try: - min_items = max(1, min(DEFAULT_MIN_ITEMS, int(min_items))) - max_items = max(min_items, min(DEFAULT_MAX_ITEMS, int(max_items))) - except (ValueError, TypeError): - logger.warning( - "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 - - return min_items, max_items - async def _get_recommendations( self, catalog_id: str, @@ -234,7 +198,7 @@ async def _get_recommendations( watched_imdb: set[str], whitelist: set[int], library_items: dict, - max_items: int, + limit: int, ) -> list[dict[str, Any]]: """Route to appropriate recommendation service based on catalog ID.""" # Item-based recommendations @@ -255,7 +219,7 @@ async def _get_recommendations( content_type=content_type, watched_tmdb=watched_tmdb, watched_imdb=watched_imdb, - limit=max_items, + limit=limit, whitelist=whitelist, ) logger.info(f"Found {len(recommendations)} recommendations for item {item_id}") @@ -270,7 +234,7 @@ async def _get_recommendations( profile=profile, watched_tmdb=watched_tmdb, watched_imdb=watched_imdb, - limit=max_items, + limit=limit, whitelist=whitelist, ) logger.info(f"Found {len(recommendations)} recommendations for theme {catalog_id}") @@ -285,7 +249,7 @@ async def _get_recommendations( content_type=content_type, watched_tmdb=watched_tmdb, watched_imdb=watched_imdb, - limit=max_items, + limit=limit, ) else: recommendations = [] @@ -302,7 +266,7 @@ async def _get_recommendations( library_items=library_items, watched_tmdb=watched_tmdb, watched_imdb=watched_imdb, - limit=max_items, + limit=limit, ) else: recommendations = [] @@ -318,7 +282,7 @@ async def _get_recommendations( watched_tmdb=watched_tmdb, watched_imdb=watched_imdb, whitelist=whitelist, - limit=max_items, + limit=limit, item_type=item_type, profile=profile, ) diff --git a/app/static/script.js b/app/static/script.js index d0b63f7..08caf30 100644 --- a/app/static/script.js +++ b/app/static/script.js @@ -1,12 +1,12 @@ // Default catalog configurations const defaultCatalogs = [ - { id: 'watchly.rec', name: 'Top Picks for You', enabled: true, enabledMovie: true, enabledSeries: true, minItems: 20, maxItems: 24, description: 'Personalized recommendations based on your library' }, - { 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: 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' }, + { id: 'watchly.rec', name: 'Top Picks for You', enabled: true, enabledMovie: true, enabledSeries: true, description: 'Personalized recommendations based on your library' }, + { id: 'watchly.loved', name: 'More Like', enabled: true, enabledMovie: true, enabledSeries: true, description: 'Recommendations similar to content you explicitly loved' }, + { id: 'watchly.watched', name: 'Because You Watched', enabled: true, enabledMovie: true, enabledSeries: true, description: 'Recommendations based on your recent watch history' }, + { id: 'watchly.creators', name: 'From your favourite Creators', enabled: false, enabledMovie: true, enabledSeries: true, 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, description: 'Recommendations based on all your loved items' }, + { id: 'watchly.liked.all', name: 'Based on what you liked', enabled: false, enabledMovie: true, enabledSeries: true, description: 'Recommendations based on all your liked items' }, + { id: 'watchly.theme', name: 'Genre & Keyword Catalogs', enabled: true, enabledMovie: true, enabledSeries: true, 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.' }, ]; let catalogs = JSON.parse(JSON.stringify(defaultCatalogs)); @@ -365,8 +365,6 @@ async function fetchStremioIdentity(authKey) { if (local) { local.enabled = remote.enabled; if (remote.name) local.name = remote.name; - if (typeof remote.min_items === 'number') local.minItems = remote.min_items; - if (typeof remote.max_items === 'number') local.maxItems = remote.max_items; if (typeof remote.enabled_movie === 'boolean') local.enabledMovie = remote.enabled_movie; if (typeof remote.enabled_series === 'boolean') local.enabledSeries = remote.enabled_series; } @@ -591,14 +589,6 @@ async function initializeFormSubmission() { const enabled = toggle.checked; const originalCatalog = catalogs.find(c => c.id === catalogId); if (originalCatalog) { - let minV = parseInt(originalCatalog.minItems ?? 20, 10); - let maxV = parseInt(originalCatalog.maxItems ?? 24, 10); - if (Number.isNaN(minV)) minV = 20; - if (Number.isNaN(maxV)) maxV = 24; - // Enforce server policy: min <= 20, max <= 32, and max >= min - minV = Math.max(1, Math.min(20, minV)); - maxV = Math.max(minV, Math.min(32, maxV)); - // Get enabled_movie and enabled_series from toggle buttons const activeBtn = document.querySelector(`.catalog-type-btn[data-catalog-id="${catalogId}"].bg-white`); let enabledMovie = true; @@ -629,8 +619,6 @@ async function initializeFormSubmission() { enabled: enabled, enabled_movie: enabledMovie, enabled_series: enabledSeries, - min_items: minV, - max_items: maxV, }); } }); From 9c0065b6339d935fa21966f825fda2cfd557bc96 Mon Sep 17 00:00:00 2001 From: Bimal Timilsina Date: Sat, 27 Dec 2025 18:15:46 +0545 Subject: [PATCH 07/11] feat: better donation button --- app/static/index.html | 280 ++++++++++++++++++++++++++++++++---------- app/static/script.js | 56 +++++++-- 2 files changed, 262 insertions(+), 74 deletions(-) diff --git a/app/static/index.html b/app/static/index.html index afb24c7..57c177d 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -45,12 +45,12 @@ /* Modern layered dark background: subtle vignette + ultra-faint grid */ background-color: #0c0d10; background-image: - radial-gradient(1200px 600px at 50% -10%, rgba(255,255,255,0.05), transparent 60%), - radial-gradient(800px 400px at 85% -5%, rgba(255,255,255,0.03), transparent 60%), - radial-gradient(600px 300px at 10% -5%, rgba(255,255,255,0.025), transparent 60%), - linear-gradient(to bottom, rgba(255,255,255,0.02), transparent 200px), - repeating-linear-gradient(90deg, rgba(255,255,255,0.015) 0, rgba(255,255,255,0.015) 1px, transparent 1px, transparent 24px), - repeating-linear-gradient(0deg, rgba(255,255,255,0.012) 0, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 24px); + radial-gradient(1200px 600px at 50% -10%, rgba(255, 255, 255, 0.05), transparent 60%), + radial-gradient(800px 400px at 85% -5%, rgba(255, 255, 255, 0.03), transparent 60%), + radial-gradient(600px 300px at 10% -5%, rgba(255, 255, 255, 0.025), transparent 60%), + linear-gradient(to bottom, rgba(255, 255, 255, 0.02), transparent 200px), + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.015) 0, rgba(255, 255, 255, 0.015) 1px, transparent 1px, transparent 24px), + repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.012) 0, rgba(255, 255, 255, 0.012) 1px, transparent 1px, transparent 24px); background-blend-mode: screen, screen, screen, normal, normal, normal; } @@ -86,7 +86,8 @@ /* Announcement link styling to ensure visibility (neutral theme) */ #announcement-content a { - color: #e5e7eb; /* slate-200 */ + color: #e5e7eb; + /* slate-200 */ text-decoration: underline; } @@ -110,11 +111,13 @@ height: 100dvh; overflow-y: auto; } + main { position: fixed; top: 0; right: 0; - left: 288px; /* w-72 = 18rem = 288px */ + left: 288px; + /* w-72 = 18rem = 288px */ height: 100dvh; overflow-y: auto; } @@ -122,23 +125,33 @@ /* High-contrast text selection to avoid blending with blue OS highlight */ ::selection { - background: rgba(59, 130, 246, 0.35); /* blue-500 @ 35% */ + background: rgba(59, 130, 246, 0.35); + /* blue-500 @ 35% */ color: #ffffff; } + ::-moz-selection { background: rgba(59, 130, 246, 0.35); color: #ffffff; } - input::selection, textarea::selection { + + input::selection, + textarea::selection { background: rgba(59, 130, 246, 0.45); color: #ffffff; } - input::-moz-selection, textarea::-moz-selection { + + input::-moz-selection, + textarea::-moz-selection { background: rgba(59, 130, 246, 0.45); color: #ffffff; } + /* Ensure caret is visible on dark inputs */ - input, textarea { caret-color: #ffffff; } + input, + textarea { + caret-color: #ffffff; + } /* Animated hamburger icon */ .hamburger { @@ -146,26 +159,47 @@ width: 40px; height: 40px; } + .hamburger .bar { position: absolute; left: 9px; right: 9px; height: 2px; - background: #e5e7eb; /* slate-200 */ + background: #e5e7eb; + /* slate-200 */ border-radius: 2px; transform-origin: center; transition: transform 200ms ease, opacity 180ms ease; } - .hamburger .bar.top { top: 12px; } - .hamburger .bar.middle { top: 19px; } - .hamburger .bar.bottom { top: 26px; } - .hamburger.is-active .bar.top { transform: translateY(7px) rotate(45deg); } - .hamburger.is-active .bar.middle { opacity: 0; } - .hamburger.is-active .bar.bottom { transform: translateY(-7px) rotate(-45deg); } + .hamburger .bar.top { + top: 12px; + } + + .hamburger .bar.middle { + top: 19px; + } + + .hamburger .bar.bottom { + top: 26px; + } + + .hamburger.is-active .bar.top { + transform: translateY(7px) rotate(45deg); + } + + .hamburger.is-active .bar.middle { + opacity: 0; + } + + .hamburger.is-active .bar.bottom { + transform: translateY(-7px) rotate(-45deg); + } @media (prefers-reduced-motion: reduce) { - .hamburger .bar { transition: none; } + .hamburger .bar { + transition: none; + } } /* Number input: hide native spinners; we provide custom +/- */ @@ -174,6 +208,7 @@ -webkit-appearance: none; margin: 0; } + input.stepper-input[type=number] { -moz-appearance: textfield; } @@ -297,21 +332,18 @@

- + @@ -332,7 +364,8 @@

-
+
Version
@@ -469,16 +502,17 @@

Based on Your Loves

Recommendations from content you loved

- +

-
- -
- + -
+
Why email & password? - We store your credentials securely to generate a fresh Stremio auth key automatically when needed. This avoids expired keys and keeps your addon working without manual re-login. - Prefer not to share your password? Use the Stremio login above to supply an auth key. Note: auth keys can expire and may require periodic re-authentication. + We store your credentials securely to generate a fresh Stremio auth + key automatically when needed. This avoids expired keys and keeps your addon working + without manual re-login. + Prefer not to share your password? Use the Stremio login above to + supply an auth key. Note: auth keys can expire and may require periodic + re-authentication.
@@ -643,7 +689,8 @@

Preferences

placeholder="Paste your RPDB API key here">

Enable ratings on posters via RatingPosterDB.

+ href="https://ratingposterdb.com" target="_blank" + class="text-slate-300 hover:text-white underline">RatingPosterDB.

- + @@ -746,7 +795,7 @@

Danger Zone

You're all set!

Your personalized catalog is ready.

-
https://...
You're all set!
- + +
@@ -152,7 +152,7 @@ function setupRenameLogic(item, cat) { const renameBtn = item.querySelector('.rename-btn'); const editActions = document.createElement('div'); - editActions.className = 'edit-actions hidden absolute right-1 top-1/2 -translate-y-1/2 flex gap-1 bg-slate-900 pl-2 z-10'; + editActions.className = 'edit-actions hidden absolute right-1 top-1/2 -translate-y-1/2 flex gap-1 bg-neutral-900 pl-2 z-10'; editActions.innerHTML = ` diff --git a/app/static/js/modules/form.js b/app/static/js/modules/form.js index a446ea4..619a6ae 100644 --- a/app/static/js/modules/form.js +++ b/app/static/js/modules/form.js @@ -144,7 +144,7 @@ function renderGenreList(container, genres, namePrefix) {