diff --git a/app/api/endpoints/manifest.py b/app/api/endpoints/manifest.py index 2534368..cffbc2f 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", @@ -53,8 +41,14 @@ def get_base_manifest(user_settings: UserSettings | None = None): } -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, @@ -62,7 +56,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 +73,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 +105,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 +148,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/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" 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..9d10d2f --- /dev/null +++ b/app/services/recommendation/all_based.py @@ -0,0 +1,178 @@ +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 + +TOP_ITEMS_LIMIT = 10 + + +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 [] + + # We'll process them in parallel + top_items = typed_items[:TOP_ITEMS_LIMIT] + + 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 = [] + 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 + try: + res = await self.tmdb_service.get_recommendations(tmdb_id, mtype, page=1) + for item in res.get("results", []): + 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}") + + return list(combined.values()) diff --git a/app/services/recommendation/catalog_service.py b/app/services/recommendation/catalog_service.py index cb08750..86177a2 100644 --- a/app/services/recommendation/catalog_service.py +++ b/app/services/recommendation/catalog_service.py @@ -11,6 +11,7 @@ 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 @@ -20,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): @@ -98,11 +102,12 @@ async def get_catalog( ) # Pad if needed - if len(recommendations) < min_items: + # TODO: This is risky because it can fetch too many unrelated items. + if recommendations and len(recommendations) < PAD_RECOMMENDATIONS_THRESHOLD: recommendations = await pad_to_min( content_type, recommendations, - min_items, + PAD_RECOMMENDATIONS_TARGET, services["tmdb"], user_settings, watched_tmdb, @@ -131,18 +136,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 +192,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 +217,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 +308,22 @@ async def _get_recommendations( recommendations = [] logger.info(f"Found {len(recommendations)} top picks for {content_type}") + # Based on what you loved + 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, + content_type=content_type, + watched_tmdb=watched_tmdb, + watched_imdb=watched_imdb, + whitelist=whitelist, + limit=max_items, + item_type=item_type, + profile=profile, + ) + logger.info(f"Found {len(recommendations)} recommendations based on all {item_type} 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"