diff --git a/app/api/endpoints/catalogs.py b/app/api/endpoints/catalogs.py index 75d52f3..eddc972 100644 --- a/app/api/endpoints/catalogs.py +++ b/app/api/endpoints/catalogs.py @@ -40,9 +40,7 @@ async def get_catalog( # Supported IDs now include dynamic themes and item-based rows if ( id != "watchly.rec" - and not id.startswith("tt") - and not id.startswith("watchly.theme.") - and not id.startswith("watchly.item.") + and not any(id.startswith(p) for p in ("tt", "watchly.theme.", "watchly.item.", "watchly.loved.", "watchly.watched.")) ): logger.warning(f"Invalid id: {id}") raise HTTPException( @@ -72,9 +70,9 @@ async def get_catalog( recommendations = await recommendation_service.get_recommendations_for_item(item_id=id) logger.info(f"Found {len(recommendations)} recommendations for {id}") - elif id.startswith("watchly.item."): + elif id.startswith("watchly.item.") or id.startswith("watchly.loved.") or id.startswith("watchly.watched."): # Extract actual item ID (tt... or tmdb:...) - item_id = id.replace("watchly.item.", "") + item_id = re.sub(r"^watchly\.(item|loved|watched)\.", "", id) recommendations = await recommendation_service.get_recommendations_for_item(item_id=item_id) logger.info(f"Found {len(recommendations)} recommendations for item {item_id}") @@ -107,8 +105,7 @@ async def get_catalog( @router.get("/{token}/catalog/update") -@router.get("/{settings_str}/{token}/catalog/update") -async def update_catalogs(token: str, settings_str: str | None = None): +async def update_catalogs(token: str): """ Update the catalogs for the addon. This is a manual endpoint to update the catalogs. """ diff --git a/app/api/endpoints/manifest.py b/app/api/endpoints/manifest.py index fbd065f..3673798 100644 --- a/app/api/endpoints/manifest.py +++ b/app/api/endpoints/manifest.py @@ -130,6 +130,10 @@ def get_config_id(catalog): catalog_id = catalog.get("id", "") if catalog_id.startswith("watchly.theme."): return "watchly.theme" + if catalog_id.startswith("watchly.loved."): + 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"): diff --git a/app/core/settings.py b/app/core/settings.py index 924d599..6528ebb 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -50,7 +50,8 @@ def get_default_settings() -> UserSettings: language="en-US", catalogs=[ CatalogConfig(id="watchly.rec", name="Recommended", enabled=True), - CatalogConfig(id="watchly.item", name="Because you Loved/Watched", enabled=True), + CatalogConfig(id="watchly.loved", name="More like what you loved", enabled=True), + CatalogConfig(id="watchly.watched", name="Because you watched", enabled=True), CatalogConfig(id="watchly.theme", name="Because of Genre/Theme", enabled=True), ], ) diff --git a/app/core/version.py b/app/core/version.py index deba3c4..453e710 100644 --- a/app/core/version.py +++ b/app/core/version.py @@ -1 +1 @@ -__version__ = "1.0.0-rc.4" +__version__ = "1.0.0-rc.5" diff --git a/app/models/profile.py b/app/models/profile.py index 87c396d..a96f6cb 100644 --- a/app/models/profile.py +++ b/app/models/profile.py @@ -88,3 +88,6 @@ def get_top_crew(self, limit: int = 2) -> list[tuple[int, float]]: def get_top_countries(self, limit: int = 2) -> list[tuple[str, float]]: return self.countries.get_top_features(limit) + + def get_top_year(self, limit: int = 1) -> list[tuple[int, float]]: + return self.years.get_top_features(limit) diff --git a/app/services/catalog.py b/app/services/catalog.py index 85ea7c3..003df01 100644 --- a/app/services/catalog.py +++ b/app/services/catalog.py @@ -1,4 +1,4 @@ -from app.core.settings import UserSettings +from app.core.settings import CatalogConfig, UserSettings from app.services.row_generator import RowGeneratorService from app.services.scoring import ScoringService from app.services.stremio_service import StremioService @@ -26,7 +26,7 @@ def normalize_type(type_): def build_catalog_entry(self, item, label, config_id): item_id = item.get("_id", "") # Use watchly.{config_id}.{item_id} format for better organization - if config_id == "watchly.item": + if config_id in ["watchly.item", "watchly.loved", "watchly.watched"]: # New Item-based catalog format catalog_id = f"{config_id}.{item_id}" elif item_id.startswith("tt") and config_id in ["watchly.loved", "watchly.watched"]: @@ -103,35 +103,38 @@ async def get_dynamic_catalogs( """ Generate all dynamic catalog rows. """ - lang = user_settings.language if user_settings else "en-US" - - include_item_based_rows = bool( - next((c for c in user_settings.catalogs if c.id == "watchly.item" and c.enabled), True) - ) - include_theme_based_rows = bool( - next((c for c in user_settings.catalogs if c.id == "watchly.theme" and c.enabled), True) - ) catalogs = [] + lang = user_settings.language if user_settings else "en-US" - if include_theme_based_rows: + # Theme Based + theme_config = next((c for c in user_settings.catalogs if c.id == "watchly.theme"), None) + if not theme_config or theme_config.enabled: catalogs.extend(await self.get_theme_based_catalogs(library_items, user_settings)) - # 3. Add Item-Based Rows - if include_item_based_rows: - # For Movies - await self._add_item_based_rows(catalogs, library_items, "movie", lang) - # For Series - await self._add_item_based_rows(catalogs, library_items, "series", lang) + # Item Based (Loved/Watched) + loved_config = next((c for c in user_settings.catalogs if c.id == "watchly.loved"), None) + watched_config = next((c for c in user_settings.catalogs if c.id == "watchly.watched"), None) + + # Fallback for old settings (watchly.item) + if not loved_config and not watched_config: + old_config = next((c for c in user_settings.catalogs if c.id == "watchly.item"), None) + if old_config and old_config.enabled: + # Create temporary configs + loved_config = CatalogConfig(id="watchly.loved", name=None, enabled=True) + watched_config = CatalogConfig(id="watchly.watched", name=None, enabled=True) + + # Movies + await self._add_item_based_rows(catalogs, library_items, "movie", lang, loved_config, watched_config) + # Series + await self._add_item_based_rows(catalogs, library_items, "series", lang, loved_config, watched_config) return catalogs - async def _add_item_based_rows(self, catalogs: list, library_items: dict, content_type: str, language: str): + async def _add_item_based_rows( + self, catalogs: list, library_items: dict, content_type: str, language: str, loved_config, watched_config + ): """Helper to add 'Because you watched' and 'More like' rows.""" - # Translate labels - label_more_like = await translation_service.translate("More like", language) - label_bc_watched = await translation_service.translate("Because you watched", language) - # Helper to parse date def get_date(item): import datetime @@ -154,24 +157,35 @@ def get_date(item): return datetime.datetime.min.replace(tzinfo=datetime.UTC) # 1. More Like - loved = [i for i in library_items.get("loved", []) if i.get("type") == content_type] - loved.sort(key=get_date, reverse=True) + last_loved = None # Initialize for the watched check + if loved_config and loved_config.enabled: + loved = [i for i in library_items.get("loved", []) if i.get("type") == content_type] + loved.sort(key=get_date, reverse=True) + + last_loved = loved[0] if loved else None + if last_loved: + label = loved_config.name + if not label or label == "More like what you loved": # Default + label = await translation_service.translate("More like what you loved", language) - last_loved = loved[0] if loved else None - if last_loved: - catalogs.append(self.build_catalog_entry(last_loved, label_more_like, "watchly.item")) + catalogs.append(self.build_catalog_entry(last_loved, label, "watchly.loved")) # 2. Because you watched - watched = [i for i in library_items.get("watched", []) if i.get("type") == content_type] - watched.sort(key=get_date, reverse=True) - - last_watched = None - for item in watched: - # Avoid duplicate row if it's the same item as 'More like' - if last_loved and item.get("_id") == last_loved.get("_id"): - continue - last_watched = item - break - - if last_watched: - catalogs.append(self.build_catalog_entry(last_watched, label_bc_watched, "watchly.item")) + if watched_config and watched_config.enabled: + watched = [i for i in library_items.get("watched", []) if i.get("type") == content_type] + watched.sort(key=get_date, reverse=True) + + last_watched = None + for item in watched: + # Avoid duplicate row if it's the same item as 'More like' + if last_loved and item.get("_id") == last_loved.get("_id"): + continue + last_watched = item + break + + if last_watched: + label = watched_config.name + if not label or label == "Because you watched": + label = await translation_service.translate("Because you watched", language) + + catalogs.append(self.build_catalog_entry(last_watched, label, "watchly.watched")) diff --git a/app/services/discovery.py b/app/services/discovery.py index 35d1c3d..e56b3f2 100644 --- a/app/services/discovery.py +++ b/app/services/discovery.py @@ -152,8 +152,12 @@ async def discover_recommendations( # query 6: Top year if top_year: year = top_year[0][0] + # we store year in 10 years bucket + start_year = f"{year}-01-01" + end_year = f"{int(year) + 10}-12-31" params_rating = { - "year": year, + "primary_release_date.gte": start_year, + "primary_release_date.lte": end_year, "sort_by": "ratings.desc", "vote_count.gte": 500, **base_params, diff --git a/app/static/script.js b/app/static/script.js index ff6b3df..64a0e2f 100644 --- a/app/static/script.js +++ b/app/static/script.js @@ -1,8 +1,9 @@ // Default catalog configurations const defaultCatalogs = [ { id: 'watchly.rec', name: 'Top Picks for You', enabled: true, description: 'Personalized recommendations based on your library' }, - { id: 'watchly.item', name: 'Because you Loved/Watched', enabled: true, description: 'Recommendations based on content you interacted with' }, - { id: 'watchly.theme', name: 'Keyword Genre Based Dynamic Recommendations', enabled: true, description: 'Recommendations based on your favorite genres and themes' }, + { id: 'watchly.loved', name: 'More like what you loved', enabled: true, description: 'Recommendations similar to content you explicitly loved' }, + { id: 'watchly.watched', name: 'Because you watched', enabled: true, description: 'Recommendations based on your recent watch history' }, + { id: 'watchly.theme', name: 'Genre & Theme Collections', enabled: true, description: 'Dynamic collections based on your favorite genres' }, ]; let catalogs = JSON.parse(JSON.stringify(defaultCatalogs)); @@ -578,7 +579,7 @@ function createCatalogItem(cat, index) { item.className = `catalog-item group bg-slate-900 border border-slate-700 rounded-xl p-4 transition-all hover:border-slate-600 ${disabledClass}`; item.setAttribute('data-index', index); - const isRenamable = cat.id === 'watchly.rec'; + const isRenamable = true; item.innerHTML = `