diff --git a/app/api/endpoints/catalogs.py b/app/api/endpoints/catalogs.py index 41c34e6..7ed5da0 100644 --- a/app/api/endpoints/catalogs.py +++ b/app/api/endpoints/catalogs.py @@ -8,7 +8,8 @@ @router.get("/{token}/catalog/{type}/{id}.json") -async def get_catalog(type: str, id: str, response: Response, token: str): +@router.get("/{token}/catalog/{type}/{id}/{extra}.json") +async def get_catalog(response: Response, type: str, id: str, token: str, extra: str | None = None): """ Get catalog recommendations. diff --git a/app/core/app.py b/app/core/app.py index 13d018b..1b3950c 100644 --- a/app/core/app.py +++ b/app/core/app.py @@ -1,3 +1,4 @@ +import json from contextlib import asynccontextmanager from pathlib import Path @@ -11,7 +12,9 @@ from app.api.endpoints.meta import fetch_languages_list from app.api.main import api_router +from app.core.settings import get_default_catalogs_for_frontend from app.services.redis_service import redis_service +from app.services.tmdb.genre import movie_genres, series_genres from app.services.token_store import token_store from .config import settings @@ -82,6 +85,7 @@ async def block_missing_token_middleware(request: Request, call_next): # Initialize Jinja2 templates jinja_env = Environment(loader=FileSystemLoader(str(templates_dir))) +jinja_env.filters["tojson"] = lambda v: json.dumps(v) @app.get("/", response_class=HTMLResponse) @@ -95,6 +99,13 @@ async def configure_page(request: Request, _token: str | None = None): logger.warning(f"Failed to fetch languages for template: {e}") languages = [{"iso_639_1": "en-US", "language": "English", "country": "US"}] + # Format default catalogs for frontend + default_catalogs = get_default_catalogs_for_frontend() + + # Format genres for frontend + movie_genres_list = [{"id": str(id), "name": name} for id, name in movie_genres.items()] + series_genres_list = [{"id": str(id), "name": name} for id, name in series_genres.items()] + template = jinja_env.get_template("index.html") html_content = template.render( request=request, @@ -102,6 +113,9 @@ async def configure_page(request: Request, _token: str | None = None): app_host=settings.HOST_NAME, announcement_html=settings.ANNOUNCEMENT_HTML or "", languages=languages, + default_catalogs=default_catalogs, + movie_genres=movie_genres_list, + series_genres=series_genres_list, ) return HTMLResponse(content=html_content, media_type="text/html") diff --git a/app/core/constants.py b/app/core/constants.py index 5c1f515..e82361b 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -13,3 +13,6 @@ PROFILE_KEY: str = "watchly:profile:{token}:{content_type}" WATCHED_SETS_KEY: str = "watchly:watched_sets:{token}:{content_type}" CATALOG_KEY: str = "watchly:catalog:{token}:{type}:{id}" + + +DISCOVER_ONLY_EXTRA: list[dict] = [{"name": "genre", "isRequired": True, "options": ["All"], "optionsLimit": 1}] diff --git a/app/core/settings.py b/app/core/settings.py index c1ca9b6..1e4d4e5 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -7,6 +7,8 @@ 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") + display_at_home: bool = Field(default=True, description="Display this catalog on home page") + shuffle: bool = Field(default=False, description="Randomize order of items in this catalog") class UserSettings(BaseModel): @@ -17,6 +19,21 @@ class UserSettings(BaseModel): excluded_series_genres: list[str] = Field(default_factory=list) +# Catalog descriptions for frontend +CATALOG_DESCRIPTIONS = { + "watchly.rec": "Personalized recommendations based on your library", + "watchly.loved": "Recommendations similar to content you explicitly loved", + "watchly.watched": "Recommendations based on your recent watch history", + "watchly.creators": "Movies and series from your top 5 favorite directors and top 5 favorite actors", + "watchly.all.loved": "Recommendations based on all your loved items", + "watchly.liked.all": "Recommendations based on all your liked items", + "watchly.theme": ( + "Dynamic catalogs based on your favorite genres, keyword, countries and many more." + "Just like netflix. Example: American Horror, Based on Novel or Book etc." + ), +} + + def get_default_settings() -> UserSettings: return UserSettings( language="en-US", @@ -27,6 +44,8 @@ def get_default_settings() -> UserSettings: enabled=True, enabled_movie=True, enabled_series=True, + display_at_home=True, + shuffle=False, ), CatalogConfig( id="watchly.loved", @@ -34,6 +53,8 @@ def get_default_settings() -> UserSettings: enabled=True, enabled_movie=True, enabled_series=True, + display_at_home=True, + shuffle=False, ), CatalogConfig( id="watchly.watched", @@ -41,6 +62,8 @@ def get_default_settings() -> UserSettings: enabled=True, enabled_movie=True, enabled_series=True, + display_at_home=True, + shuffle=False, ), CatalogConfig( id="watchly.theme", @@ -48,6 +71,8 @@ def get_default_settings() -> UserSettings: enabled=True, enabled_movie=True, enabled_series=True, + display_at_home=True, + shuffle=False, ), CatalogConfig( id="watchly.creators", @@ -55,6 +80,8 @@ def get_default_settings() -> UserSettings: enabled=False, enabled_movie=True, enabled_series=True, + display_at_home=True, + shuffle=False, ), CatalogConfig( id="watchly.all.loved", @@ -62,6 +89,8 @@ def get_default_settings() -> UserSettings: enabled=False, enabled_movie=True, enabled_series=True, + display_at_home=True, + shuffle=False, ), CatalogConfig( id="watchly.liked.all", @@ -69,11 +98,33 @@ def get_default_settings() -> UserSettings: enabled=False, enabled_movie=True, enabled_series=True, + display_at_home=True, + shuffle=False, ), ], ) +def get_default_catalogs_for_frontend() -> list[dict]: + """Get default catalogs formatted for frontend JavaScript.""" + settings = get_default_settings() + catalogs = [] + for catalog in settings.catalogs: + catalogs.append( + { + "id": catalog.id, + "name": catalog.name or "", + "enabled": catalog.enabled, + "enabledMovie": catalog.enabled_movie, + "enabledSeries": catalog.enabled_series, + "display_at_home": catalog.display_at_home, + "shuffle": catalog.shuffle, + "description": CATALOG_DESCRIPTIONS.get(catalog.id, ""), + } + ) + return catalogs + + class Credentials(BaseModel): authKey: str email: str diff --git a/app/services/catalog.py b/app/services/catalog.py index 61096e8..34db071 100644 --- a/app/services/catalog.py +++ b/app/services/catalog.py @@ -5,6 +5,7 @@ from loguru import logger +from app.core.constants import DISCOVER_ONLY_EXTRA from app.core.settings import CatalogConfig, UserSettings from app.services.profile.integration import ProfileIntegration from app.services.row_generator import RowGeneratorService @@ -29,7 +30,7 @@ def __init__(self, language: str = "en-US"): def normalize_type(type_): return "series" if type_ == "tv" else type_ - def build_catalog_entry(self, item, label, config_id): + def build_catalog_entry(self, item, label, config_id, display_at_home: bool = True): item_id = item.get("_id", "") # Use watchly.{config_id}.{item_id} format for better organization if config_id in ["watchly.item", "watchly.loved", "watchly.watched"]: @@ -42,11 +43,13 @@ def build_catalog_entry(self, item, label, config_id): name = item.get("name") + extra = DISCOVER_ONLY_EXTRA if not display_at_home else [] + return { "type": self.normalize_type(item.get("type")), "id": catalog_id, "name": f"{label} {name}", - "extra": [], + "extra": extra, } def _get_smart_scored_items(self, library_items: dict, content_type: str, max_items: int = 50) -> list: @@ -111,6 +114,7 @@ async def get_theme_based_catalogs( user_settings: UserSettings | None = None, enabled_movie: bool = True, enabled_series: bool = True, + display_at_home: bool = True, ) -> list[dict]: """Build thematic catalogs by profiling items using smart sampling.""" # 1. Prepare Scored History using smart sampling (loved/liked + top watched by score) @@ -150,12 +154,14 @@ async def _generate_for_type(media_type: str, genres: list[int]): # 4. Assembly with error handling catalogs = [] + extra = DISCOVER_ONLY_EXTRA if not display_at_home else [] + for result in results: if isinstance(result, Exception): continue media_type, rows = result for row in rows: - catalogs.append({"type": media_type, "id": row.id, "name": row.title, "extra": []}) + catalogs.append({"type": media_type, "id": row.id, "name": row.title, "extra": extra}) return catalogs @@ -173,8 +179,9 @@ async def get_dynamic_catalogs(self, library_items: dict, user_settings: UserSet # Filter theme catalogs by enabled_movie/enabled_series enabled_movie = getattr(theme_cfg, "enabled_movie", True) enabled_series = getattr(theme_cfg, "enabled_series", True) + display_at_home = getattr(theme_cfg, "display_at_home", True) theme_catalogs = await self.get_theme_based_catalogs( - library_items, user_settings, enabled_movie, enabled_series + library_items, user_settings, enabled_movie, enabled_series, display_at_home ) catalogs.extend(theme_catalogs) @@ -267,7 +274,10 @@ def is_type_enabled(config, content_type: str) -> bool: last_loved = random.choice(loved[:3]) if loved else None if last_loved: label = loved_config.name if loved_config.name else "More like" - catalogs.append(self.build_catalog_entry(last_loved, label, "watchly.loved")) + loved_config_display_at_home = getattr(loved_config, "display_at_home", True) + catalogs.append( + self.build_catalog_entry(last_loved, label, "watchly.loved", loved_config_display_at_home) + ) # 2. Because you watched if watched_config and watched_config.enabled and is_type_enabled(watched_config, content_type): @@ -283,4 +293,7 @@ def is_type_enabled(config, content_type: str) -> bool: if last_watched: label = watched_config.name if watched_config.name else "Because you watched" - catalogs.append(self.build_catalog_entry(last_watched, label, "watchly.watched")) + watched_config_display_at_home = getattr(watched_config, "display_at_home", True) + catalogs.append( + self.build_catalog_entry(last_watched, label, "watchly.watched", watched_config_display_at_home) + ) diff --git a/app/services/profile/constants.py b/app/services/profile/constants.py index 627c9d6..fe38dfc 100644 --- a/app/services/profile/constants.py +++ b/app/services/profile/constants.py @@ -36,7 +36,7 @@ CAP_COUNTRY: Final[float] = 20.0 # Recency Decay (exponential decay parameters) -RECENCY_HALF_LIFE_DAYS: Final[float] = 90.0 # 90-day half-life +RECENCY_HALF_LIFE_DAYS: Final[float] = 15.0 # 15-day half-life RECENCY_DECAY_RATE: Final[float] = 0.98 # Daily decay multiplier (soft decay) # Smart Sampling @@ -49,11 +49,13 @@ # Top Picks Caps (diversity constraints) TOP_PICKS_RECENCY_CAP: Final[float] = 0.15 # Max 15% recent items (from trending/popular) -TOP_PICKS_GENRE_CAP: Final[float] = 0.30 # Max 30% per genre -TOP_PICKS_CREATOR_CAP: Final[int] = 2 # Max 2 items per creator (director/actor) -TOP_PICKS_ERA_CAP: Final[float] = 0.40 # Max 40% per era -TOP_PICKS_MIN_VOTE_COUNT: Final[int] = 300 # Minimum vote count for quality -TOP_PICKS_MIN_RATING: Final[float] = 5.0 # Minimum weighted rating for quality +TOP_PICKS_GENRE_CAP: Final[float] = 0.50 # Max 50% per genre +TOP_PICKS_CREATOR_CAP: Final[int] = 3 # Max 3 items per creator (director/actor) +TOP_PICKS_ERA_CAP: Final[float] = 0.50 # Max 50% per era +TOP_PICKS_MIN_VOTE_COUNT: Final[int] = 250 # Lower noise filter +TOP_PICKS_MIN_RATING: Final[float] = 7.2 # Minimum weighted rating + +MAXIMUM_POPULARITY_SCORE: Final[float] = 15 # Genre whitelist limit (top N genres) -GENRE_WHITELIST_LIMIT: Final[int] = 5 +GENRE_WHITELIST_LIMIT: Final[int] = 7 diff --git a/app/services/profile/sampling.py b/app/services/profile/sampling.py index efd3fff..1bc87c5 100644 --- a/app/services/profile/sampling.py +++ b/app/services/profile/sampling.py @@ -69,8 +69,8 @@ def sample_items( if not (it.get("_is_loved") or it.get("_is_liked") or it.get("_id") in added_item_ids) ] - # Always include strong signal items: Loved/Liked: 45%, Added: 20% - strong_signal_items = loved_liked_items[: int(max_items * 0.45)] + added_items[: int(max_items * 0.20)] + # Always include strong signal items: Loved/Liked: 40%, Added: 20% + strong_signal_items = loved_liked_items[: int(max_items * 0.40)] + added_items[: int(max_items * 0.20)] strong_signal_scored = [self.scoring_service.process_item(it) for it in strong_signal_items] # Score watched items and sort by score diff --git a/app/services/profile/scorer.py b/app/services/profile/scorer.py index 08e7713..a5e7791 100644 --- a/app/services/profile/scorer.py +++ b/app/services/profile/scorer.py @@ -13,8 +13,6 @@ class ProfileScorer: """ Scores items against taste profile using unified function. - - Design principle: Same function everywhere, no special cases. """ @staticmethod @@ -96,7 +94,7 @@ def _extract_cast_ids(item_metadata: dict[str, Any]) -> list[int]: cast_ids = [] credits = item_metadata.get("credits", {}) or {} cast_list = credits.get("cast", []) or [] - for actor in cast_list[:10]: # Top 10 only + for actor in cast_list[:5]: # Top 5 only if isinstance(actor, dict): actor_id = actor.get("id") if actor_id: diff --git a/app/services/recommendation/all_based.py b/app/services/recommendation/all_based.py index c7b7d8c..44b0531 100644 --- a/app/services/recommendation/all_based.py +++ b/app/services/recommendation/all_based.py @@ -119,8 +119,6 @@ async def get_recommendations_from_all_items( profile=profile, scorer=self.scorer, mtype=mtype, - is_ranked=False, - is_fresh=False, ) # Apply genre multiplier (if whitelist available) diff --git a/app/services/recommendation/catalog_service.py b/app/services/recommendation/catalog_service.py index 612ddf6..84b9b3b 100644 --- a/app/services/recommendation/catalog_service.py +++ b/app/services/recommendation/catalog_service.py @@ -1,3 +1,4 @@ +import random import re from typing import Any @@ -24,6 +25,19 @@ from app.utils.catalog import cache_profile_and_watched_sets +def should_shuffle(user_settings: UserSettings, catalog_id: str) -> bool: + config = next((c for c in user_settings.catalogs if c.id == catalog_id), None) + return getattr(config, "shuffle", False) if config else False + + +def shuffle_data_if_needed( + user_settings: UserSettings, catalog_id: str, data: list[dict[str, Any]] +) -> list[dict[str, Any]]: + if should_shuffle(user_settings, catalog_id): + random.shuffle(data) + return data + + def _clean_meta(meta: dict) -> dict: """Return a sanitized Stremio meta object without internal fields. @@ -93,21 +107,25 @@ async def get_catalog( # continue with the request even if the auto update fails pass + bundle = StremioBundle() + # Resolve auth and settings + auth_key = await self._resolve_auth(bundle, credentials, token) + user_settings = self._extract_settings(credentials) + # get cached catalog cached_data = await user_cache.get_catalog(token, content_type, catalog_id) if cached_data: logger.debug(f"[{redact_token(token)}...] Using cached catalog for {content_type}/{catalog_id}") + meta_data = cached_data["metas"] + meta_data = shuffle_data_if_needed(user_settings, catalog_id, meta_data) + cached_data["metas"] = meta_data return cached_data, headers logger.info( f"[{redact_token(token)}...] Catalog not cached for {content_type}/{catalog_id}, building from" " scratch" ) - bundle = StremioBundle() try: - # Resolve auth and settings - auth_key = await self._resolve_auth(bundle, credentials, token) - user_settings = self._extract_settings(credentials) language = user_settings.language if user_settings else "en-US" # Try to get cached library items first @@ -173,6 +191,8 @@ async def get_catalog( cleaned = [_clean_meta(m) for m in recommendations] cleaned = [m for m in cleaned if m is not None] + cleaned = shuffle_data_if_needed(user_settings, catalog_id, cleaned) + data = {"metas": cleaned} # if catalog data is not empty, set the cache if cleaned: diff --git a/app/services/recommendation/filtering.py b/app/services/recommendation/filtering.py index 4894cab..df4a739 100644 --- a/app/services/recommendation/filtering.py +++ b/app/services/recommendation/filtering.py @@ -143,10 +143,6 @@ def get_genre_multiplier(genre_ids: list[int] | None, whitelist: set[int]) -> fl if not gids: return 1.0 - # Special handling for Animation (16): Heavy penalty if not in whitelist - if 16 in gids and 16 not in whitelist: - return 0.1 - # If it has at least one preferred genre, full score if gids & whitelist: return 1.0 diff --git a/app/services/recommendation/item_based.py b/app/services/recommendation/item_based.py index fdfd07b..67dc701 100644 --- a/app/services/recommendation/item_based.py +++ b/app/services/recommendation/item_based.py @@ -93,18 +93,23 @@ async def _fetch_candidates(self, tmdb_id: int, mtype: str) -> list[dict[str, An """ combined = {} - results = await asyncio.gather( - *[self.tmdb_service.get_recommendations(tmdb_id, mtype, page=p) for p in [1, 2]], - return_exceptions=True, - ) - - for res in results: - if isinstance(res, Exception): - logger.warning(f"Error fetching recommendations for {tmdb_id}: {res}") - continue - for item in res.get("results", []): - item_id = item.get("id") - if item_id: - combined[item_id] = item + async def fetch_and_combine(fetch_method, source_name): + results = await asyncio.gather( + *[fetch_method(tmdb_id, mtype, page=p) for p in [1, 2]], + return_exceptions=True, + ) + for res in results: + if isinstance(res, Exception): + logger.warning(f"Error fetching {source_name} for {tmdb_id}: {res}") + continue + for item in res.get("results", []): + item_id = item.get("id") + if item_id: + combined[item_id] = item + + await fetch_and_combine(self.tmdb_service.get_recommendations, "recommendations") + + if not combined: + await fetch_and_combine(self.tmdb_service.get_similar, "similar") return list(combined.values()) diff --git a/app/services/recommendation/scoring.py b/app/services/recommendation/scoring.py index 20ecf02..dbd9620 100644 --- a/app/services/recommendation/scoring.py +++ b/app/services/recommendation/scoring.py @@ -4,6 +4,7 @@ from typing import Any from app.core.constants import DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_MOVIE, DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_TV +from app.services.profile.constants import MAXIMUM_POPULARITY_SCORE class RecommendationScoring: @@ -12,7 +13,7 @@ class RecommendationScoring: """ @staticmethod - def weighted_rating(vote_avg: float | None, vote_count: int | None, C: float = 6.8, m: int = 300) -> float: + def weighted_rating(vote_avg: float | None, vote_count: int | None, C: float = 6.8, m: int = 150) -> float: """IMDb-style weighted rating on 0-10 scale.""" try: R = float(vote_avg or 0.0) @@ -91,31 +92,16 @@ def m_raw(year: int | None) -> float: return m_raw, alpha @staticmethod - def apply_quality_adjustments(score: float, wr: float, vote_count: int, is_ranked: bool, is_fresh: bool) -> float: - """Apply multiplicative adjustments based on item quality and source.""" - q_mult = 1.0 - if vote_count < 50: - q_mult *= 0.6 - elif vote_count < 150: - q_mult *= 0.85 - - if wr < 5.5: - q_mult *= 0.5 - elif wr < 6.0: - q_mult *= 0.7 - elif wr >= 7.0 and vote_count >= 500: - q_mult *= 1.10 - - if is_ranked: - if wr >= 6.5 and vote_count >= 200: - q_mult *= 1.25 - elif wr >= 6.0 and vote_count >= 100: - q_mult *= 1.10 - - if is_fresh and wr >= 7.0 and vote_count >= 300: - q_mult *= 1.10 - - return score * q_mult + def apply_quality_adjustments(score: float, wr: float, vote_count: int, popularity: float) -> float: + """Apply simple quality boost for high-confidence items only.""" + if vote_count >= 1000 and wr >= 7.5 and popularity <= MAXIMUM_POPULARITY_SCORE: + # Proven gem: high confidence, high quality + return score * 1.10 + elif vote_count >= 500 and wr >= 7.0 and popularity <= MAXIMUM_POPULARITY_SCORE: + # Good confidence and quality + return score * 1.05 + + return score @staticmethod def calculate_final_score( @@ -123,8 +109,6 @@ def calculate_final_score( profile: Any, scorer: Any, mtype: str, - is_ranked: bool = False, - is_fresh: bool = False, ) -> float: # noqa: E501 """ Calculate final recommendation score combining profile similarity and quality. @@ -134,8 +118,6 @@ def calculate_final_score( profile: User taste profile scorer: ProfileScorer instance mtype: Media type (movie/tv) to determine minimum rating - is_ranked: Whether item is from ranked source - is_fresh: Whether item should get freshness boost minimum_rating_tv: Minimum rating constant for TV minimum_rating_movie: Minimum rating constant for movies @@ -158,13 +140,12 @@ def calculate_final_score( ) quality_score = RecommendationScoring.normalize(wr) - # Apply quality adjustments - vote_count = item.get("vote_count", 0) - adjusted_profile_score = RecommendationScoring.apply_quality_adjustments( - profile_score, wr, vote_count, is_ranked=is_ranked, is_fresh=is_fresh - ) + # Simple weighted combination: profile match is primary, quality ensures no bad items + base_score = (profile_score * 0.70) + (quality_score * 0.30) - # Combined score: profile similarity (with quality adjustments) + quality - final_score = (adjusted_profile_score * 0.6) + (quality_score * 0.4) + # light boost for high-confidence items (no penalties!) + vote_count = item.get("vote_count", 0) + popularity = item.get("popularity", 0) + final_score = RecommendationScoring.apply_quality_adjustments(base_score, wr, vote_count, popularity) return final_score diff --git a/app/services/recommendation/theme_based.py b/app/services/recommendation/theme_based.py index bbd35ad..6755f42 100644 --- a/app/services/recommendation/theme_based.py +++ b/app/services/recommendation/theme_based.py @@ -1,14 +1,17 @@ import asyncio +from datetime import datetime from typing import Any from loguru import logger from app.models.taste_profile import TasteProfile +from app.services.profile.constants import TOP_PICKS_MIN_RATING, TOP_PICKS_MIN_VOTE_COUNT 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 +from app.services.tmdb.service import TMDBService class ThemeBasedService: @@ -24,7 +27,7 @@ class ThemeBasedService: """ def __init__(self, tmdb_service: Any, user_settings: Any = None): - self.tmdb_service = tmdb_service + self.tmdb_service: TMDBService = tmdb_service self.user_settings = user_settings self.scorer = ProfileScorer() @@ -77,11 +80,13 @@ async def get_recommendations_for_theme( # Fetch candidates candidates = await self._fetch_discover_candidates(content_type, params, pages_to_fetch) - # Use provided whitelist (or empty set if not provided) - whitelist = whitelist or set() + # # Use provided whitelist (or empty set if not provided) + # whitelist = whitelist or set() - # Initial filter (watched + genre whitelist) - filtered = self._filter_candidates(candidates, watched_tmdb, whitelist) + # # Initial filter (watched + genre whitelist) + # filtered = self._filter_candidates(candidates, watched_tmdb, whitelist) + + filtered = candidates # If not enough candidates, fetch more pages if len(filtered) < limit * 2 and max(pages_to_fetch) < 15: @@ -106,8 +111,6 @@ async def get_recommendations_for_theme( profile=profile, scorer=self.scorer, mtype=mtype, - is_ranked=False, - is_fresh=False, ) # Apply genre multiplier (if whitelist available) @@ -123,6 +126,9 @@ async def get_recommendations_for_theme( scored.sort(key=lambda x: x[0], reverse=True) filtered = [item for _, item in scored] + # limit items to limit *2 + filtered = filtered[: limit * 2] + # Enrich metadata enriched = await RecommendationMetadata.fetch_batch( self.tmdb_service, filtered, content_type, user_settings=self.user_settings @@ -168,17 +174,28 @@ def _parse_theme_id(self, theme_id: str, content_type: str) -> dict[str, Any]: is_tv = content_type in ("tv", "series") prefix = "first_air_date" if is_tv else "primary_release_date" params[f"{prefix}.gte"] = f"{year}-01-01" - params[f"{prefix}.lte"] = f"{year+9}-12-31" + end_year = year + 9 + end_date_str = f"{end_year}-12-31" + today_str = datetime.now().strftime("%Y-%m-%d") + # If the calculated end date is after today, use today + if end_date_str > today_str: + params[f"{prefix}.lte"] = today_str + else: + params[f"{prefix}.lte"] = end_date_str except Exception: pass elif part == "sort-vote": params["sort_by"] = "vote_average.desc" - params["vote_count.gte"] = 200 + params["vote_count.gte"] = TOP_PICKS_MIN_VOTE_COUNT # Default sort if "sort_by" not in params: params["sort_by"] = "popularity.desc" + # add default vote count and vote average + params["vote_count.gte"] = TOP_PICKS_MIN_VOTE_COUNT + params["vote_average.gte"] = TOP_PICKS_MIN_RATING + return params def _calculate_pages_to_fetch(self, num_excluded_genres: int) -> list[int]: diff --git a/app/services/recommendation/top_picks.py b/app/services/recommendation/top_picks.py index 891580c..bfe0d2f 100644 --- a/app/services/recommendation/top_picks.py +++ b/app/services/recommendation/top_picks.py @@ -1,5 +1,7 @@ import asyncio +import time from collections import defaultdict +from datetime import date, datetime, timedelta from typing import Any from loguru import logger @@ -16,6 +18,7 @@ ) from app.services.profile.sampling import SmartSampler 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_watched_by_imdb, resolve_tmdb_id @@ -48,13 +51,15 @@ async def get_top_picks( Get top picks with diversity caps. Strategy: - 1. Fetch recommendations from top items (loved/watched/liked/added) - 1 page each - 2. Fetch discover with profile features (keywords, cast, crew, era) - 3. Fetch trending and popular items - 1 page each - 4. Merge all candidates - 5. Score with ProfileScorer - 6. Apply diversity caps - 7. Return balanced results + 1. Fetch recommendations from top 8 library items - 1 page each + 2. Fetch discover with profile features (genres, keywords, cast, crew, era, country) + 3. Merge all candidates (deduped by TMDB ID) + 4. Score with ProfileScorer + Quality + 5. Apply diversity caps (relaxed: 50% genre, 50% era, 15% recent) + 6. Limit to 2x target before enrichment (performance optimization) + 7. Enrich metadata with full details + 8. Apply creator cap and final filters + 9. Return balanced results Args: profile: User taste profile @@ -67,6 +72,11 @@ async def get_top_picks( Returns: List of recommended items """ + + start_time = time.time() + + logger.info(f"Starting top picks generation for {content_type}, target limit={limit}") + mtype = content_type_to_mtype(content_type) all_candidates = {} @@ -82,53 +92,56 @@ async def get_top_picks( if item.get("id"): all_candidates[item["id"]] = item - # 3. Fetch trending and popular (for recent items injection - 10-15% cap) - trending_candidates = await self._fetch_trending_and_popular(content_type, mtype) - for item in trending_candidates: - if item.get("id"): - # Mark source for recency tracking - item["_source"] = "trending_popular" - all_candidates[item["id"]] = item - - # 4. Filter out watched items + # Filter out watched items filtered_candidates = [item for item in all_candidates.values() if item.get("id") not in watched_tmdb] - # 5. Score all candidates with profile + # Score all candidates with profile scored_candidates = [] for item in filtered_candidates: try: - is_ranked = item.get("_ranked_candidate", False) - is_fresh = item.get("_fresh_boost", False) final_score = RecommendationScoring.calculate_final_score( item=item, profile=profile, scorer=self.scorer, mtype=mtype, - is_ranked=is_ranked, - is_fresh=is_fresh, ) scored_candidates.append((final_score, item)) except Exception as e: logger.debug(f"Failed to score item {item.get('id')}: {e}") continue - # 6. Sort by score + # Sort by score scored_candidates.sort(key=lambda x: x[0], reverse=True) - # 7. Apply diversity caps - result = self._apply_diversity_caps(scored_candidates, limit, mtype) + logger.info(f"Scored {len(scored_candidates)} candidates.") + + # Apply diversity caps + result = self._apply_diversity_caps(scored_candidates, len(scored_candidates), mtype) + logger.info(f"After diversity caps: {len(result)} items") - # 8. Enrich metadata + # Limit before enrichment to avoid timeout (only enrich 3x what we need) + result = result[: limit * 3] + logger.info(f"After diversity caps and pre-enrichment limit: {len(result)} items") + + # Enrich metadata enriched = await RecommendationMetadata.fetch_batch( self.tmdb_service, result, content_type, user_settings=self.user_settings ) + logger.info(f"Enriched {len(enriched)} items with full metadata") - # 9. Apply creator cap (after enrichment, we have full metadata) - final = self._apply_creator_cap(enriched, limit) + # Apply creator cap (after enrichment, we have full metadata) + final = self._apply_creator_cap(enriched, len(enriched)) + logger.info(f"After creator cap: {len(final)} items") - # 10. Final filter (remove watched by IMDB ID) + # Final filter (remove watched by IMDB ID) filtered = filter_watched_by_imdb(final, watched_imdb) + elapsed_time = time.time() - start_time + logger.info( + f"✓ Top picks complete: {len(filtered)} items returned in {elapsed_time:.2f}s " + f"(target: {limit}, candidates: {len(all_candidates)}, scored: {len(scored_candidates)})" + ) + return filtered async def _fetch_recommendations_from_top_items( @@ -146,7 +159,7 @@ async def _fetch_recommendations_from_top_items( List of candidate items """ # Get top items (loved first, then liked, then added, then top watched) - top_items = self.smart_sampler.sample_items(library_items, content_type, max_items=10) + top_items = self.smart_sampler.sample_items(library_items, content_type, max_items=15) candidates = [] tasks = [] @@ -167,15 +180,37 @@ async def _fetch_recommendations_from_top_items( # tasks.append(self.tmdb_service.get_similar(tmdb_id, mtype, page=1)) # Execute all in parallel + logger.debug(f"Fetching recommendations from {len(tasks)} top library items") results = await asyncio.gather(*tasks, return_exceptions=True) + failed_count = 0 for res in results: if isinstance(res, Exception): + failed_count += 1 + logger.debug(f"Recommendation fetch failed: {res}") continue candidates.extend(res.get("results", [])) + if failed_count > 0: + logger.info(f"{failed_count}/{len(tasks)} recommendation fetches failed (expected for items with no recs)") + logger.debug(f"Fetched {len(candidates)} candidates from top items") + return candidates + def _add_discover_task(self, tasks: list, mtype: str, without_genres: str | None, **kwargs: Any) -> None: + """ + Add a discover task to the list of tasks with default parameters. + """ + params = { + "sort_by": "popularity.asc", + "vote_count_gte": TOP_PICKS_MIN_VOTE_COUNT, + "vote_average_gte": TOP_PICKS_MIN_RATING, + **kwargs, + } + if without_genres: + params["without_genres"] = without_genres + tasks.append(self.tmdb_service.get_discover(mtype, **params)) + async def _fetch_discover_with_profile( self, profile: TasteProfile, content_type: str, mtype: str ) -> list[dict[str, Any]]: @@ -190,13 +225,19 @@ async def _fetch_discover_with_profile( Returns: List of candidate items """ + + excluded_genre_ids = RecommendationFiltering.get_excluded_genre_ids(self.user_settings, content_type) + without_genres = "|".join(str(g) for g in excluded_genre_ids) if excluded_genre_ids else None + + logger.debug(f"Excluded genres for {content_type}: {excluded_genre_ids}") + # Get top features from profile - top_genres = profile.get_top_genres(limit=2) - top_keywords = profile.get_top_keywords(limit=3) - top_directors = profile.get_top_directors(limit=2) - top_cast = profile.get_top_cast(limit=2) - top_eras = profile.get_top_eras(limit=1) - top_countries = profile.get_top_countries(limit=1) + top_genres = profile.get_top_genres(limit=5) + top_keywords = profile.get_top_keywords(limit=5) + top_directors = profile.get_top_directors(limit=3) + top_cast = profile.get_top_cast(limit=5) + top_eras = profile.get_top_eras(limit=2) + top_countries = profile.get_top_countries(limit=5) candidates = [] tasks = [] @@ -204,58 +245,29 @@ async def _fetch_discover_with_profile( # Discover with genres if top_genres: genre_ids = [g[0] for g in top_genres] - tasks.append( - self.tmdb_service.get_discover( - mtype, - with_genres="|".join(str(g) for g in genre_ids), - page=1, - sort_by="popularity.desc", - vote_count_gte=TOP_PICKS_MIN_VOTE_COUNT, - vote_average_gte=TOP_PICKS_MIN_RATING, - ) + self._add_discover_task( + tasks, mtype, without_genres, with_genres="|".join(str(g) for g in genre_ids), page=1 ) # Discover with keywords if top_keywords: keyword_ids = [k[0] for k in top_keywords] - tasks.append( - self.tmdb_service.get_discover( - mtype, - with_keywords="|".join(str(k) for k in keyword_ids), - page=1, - sort_by="popularity.desc", - vote_count_gte=TOP_PICKS_MIN_VOTE_COUNT, - vote_average_gte=TOP_PICKS_MIN_RATING, + for page in range(1, 3): # 2 pages + self._add_discover_task( + tasks, mtype, without_genres, with_keywords="|".join(str(k) for k in keyword_ids), page=page ) - ) # Discover with directors if top_directors: - director_id = top_directors[0][0] - tasks.append( - self.tmdb_service.get_discover( - mtype, - with_crew=str(director_id), - page=1, - sort_by="popularity.desc", - vote_count_gte=TOP_PICKS_MIN_VOTE_COUNT, - vote_average_gte=TOP_PICKS_MIN_RATING, - ) + director_ids = [d[0] for d in top_directors] + self._add_discover_task( + tasks, mtype, without_genres, with_crew="|".join(str(d) for d in director_ids), page=1 ) # Discover with cast if top_cast: - cast_id = top_cast[0][0] - tasks.append( - self.tmdb_service.get_discover( - mtype, - with_cast=str(cast_id), - page=1, - sort_by="popularity.desc", - vote_count_gte=TOP_PICKS_MIN_VOTE_COUNT, - vote_average_gte=TOP_PICKS_MIN_RATING, - ) - ) + cast_ids = [c[0] for c in top_cast] + self._add_discover_task(tasks, mtype, without_genres, with_cast="|".join(str(c) for c in cast_ids), page=1) # Discover with era (year range) if top_eras: @@ -263,39 +275,40 @@ async def _fetch_discover_with_profile( year_start = self._era_to_year_start(era) if year_start: prefix = "first_air_date" if mtype == "tv" else "primary_release_date" - tasks.append( - self.tmdb_service.get_discover( - mtype, - **{f"{prefix}.gte": f"{year_start}-01-01", f"{prefix}.lte": f"{year_start+9}-12-31"}, - page=1, - sort_by="popularity.desc", - vote_count_gte=TOP_PICKS_MIN_VOTE_COUNT, - vote_average_gte=TOP_PICKS_MIN_RATING, - ) - ) + lte_prefix = date.today().isoformat() if year_start + 9 > date.today().year else f"{year_start+9}-12-31" + params = { + f"{prefix}.gte": f"{year_start}-01-01", + f"{prefix}.lte": lte_prefix, + "page": 1, + } + + self._add_discover_task(tasks, mtype, without_genres, **params) # Discover with countries if top_countries: country_codes = [c[0] for c in top_countries] - tasks.append( - self.tmdb_service.get_discover( - mtype, - with_origin_country="|".join(country_codes), - page=1, - sort_by="popularity.desc", - vote_count_gte=TOP_PICKS_MIN_VOTE_COUNT, - vote_average_gte=TOP_PICKS_MIN_RATING, - ) - ) + params = { + "with_origin_country": "|".join(country_codes), + "page": 1, + } + self._add_discover_task(tasks, mtype, without_genres, **params) # Execute all in parallel + logger.debug(f"Fetching {len(tasks)} discover queries with profile features") results = await asyncio.gather(*tasks, return_exceptions=True) + failed_count = 0 for res in results: if isinstance(res, Exception): + failed_count += 1 + logger.warning(f"Discover query failed: {res}") continue candidates.extend(res.get("results", [])) + if failed_count > 0: + logger.warning(f"{failed_count}/{len(tasks)} discover queries failed") + logger.debug(f"Fetched {len(candidates)} candidates from discover") + return candidates async def _fetch_trending_and_popular(self, content_type: str, mtype: str) -> list[dict[str, Any]]: @@ -318,13 +331,6 @@ async def _fetch_trending_and_popular(self, content_type: str, mtype: str) -> li except Exception as e: logger.debug(f"Failed to fetch trending: {e}") - # Fetch popular (top rated, 1 page) - try: - popular = await self.tmdb_service.get_top_rated(mtype, page=1) - candidates.extend(popular.get("results", [])) - except Exception as e: - logger.debug(f"Failed to fetch popular: {e}") - return candidates def _apply_diversity_caps( @@ -334,10 +340,10 @@ def _apply_diversity_caps( Apply diversity caps to ensure balanced results. Caps: - - Recent items: max 15% (from trending/popular) - - Genre: max 30% per genre - - Creator: max 2 items per creator - - Era: max 40% per era + - Recency: max 15% from items released in last year + - Genre: max 50% per genre + - Creator: max 3 items per creator + - Era: max 50% per era - Quality: minimum vote_count and rating Args: @@ -353,9 +359,8 @@ def _apply_diversity_caps( era_counts = defaultdict(int) recent_count = 0 - # # Determine recent threshold (6 months ago) - # recent_threshold = datetime.now() - timedelta(days=180) - + # Determine recent threshold (1 year ago) + recent_threshold = datetime.now() - timedelta(days=365) max_recent = int(limit * TOP_PICKS_RECENCY_CAP) max_per_genre = int(limit * TOP_PICKS_GENRE_CAP) max_per_era = int(limit * TOP_PICKS_ERA_CAP) @@ -378,20 +383,20 @@ def _apply_diversity_caps( if wr < TOP_PICKS_MIN_RATING: continue - # Check recency cap (15% max from trending/popular sources) - # Recent items come from trending/popular, so track by source - is_from_trending_popular = item.get("_source") == "trending_popular" - if is_from_trending_popular and recent_count >= max_recent: + # Check recency cap (15% max from items released in last 6 months) + # Check release date against threshold + is_recent = self._is_recent_release(item, recent_threshold, mtype) + if is_recent and recent_count >= max_recent: continue - # Check genre cap (30% max per genre) + # Check genre cap (50% max per genre) genre_ids = item.get("genre_ids", []) if genre_ids: top_genre = genre_ids[0] # Primary genre if genre_counts[top_genre] >= max_per_genre: continue - # Check era cap (40% max per era) + # Check era cap (50% max per era) year = self._extract_year(item) if year: era = self._year_to_era(year) @@ -402,7 +407,7 @@ def _apply_diversity_caps( result.append(item) # Update counts - if is_from_trending_popular: + if is_recent: recent_count += 1 if genre_ids: genre_counts[top_genre] += 1 @@ -476,6 +481,20 @@ def _extract_year(item: dict[str, Any]) -> int | None: pass return None + @staticmethod + def _is_recent_release(item: dict[str, Any], threshold: datetime, mtype: str) -> bool: + """Check if item was released within the threshold (e.g., last 6 months).""" + release_date_str = item.get("release_date") if mtype == "movie" else item.get("first_air_date") + if not release_date_str: + return False + + try: + # Parse release date (format: YYYY-MM-DD) + release_date = datetime.strptime(str(release_date_str)[:10], "%Y-%m-%d") + return release_date >= threshold + except (ValueError, TypeError): + return False + @staticmethod def _year_to_era(year: int) -> str: """Convert year to era bucket.""" diff --git a/app/services/recommendation/utils.py b/app/services/recommendation/utils.py index 9db1cbf..4ca1303 100644 --- a/app/services/recommendation/utils.py +++ b/app/services/recommendation/utils.py @@ -167,7 +167,7 @@ async def pad_to_min( # Quality threshold va, vc = float(it.get("vote_average") or 0.0), int(it.get("vote_count") or 0) - if vc < 100 or va < 6.2: + if vc < 200 or va < 6.0: continue dedup[tid] = it if len(dedup) >= need * 3: diff --git a/app/static/js/constants.js b/app/static/js/constants.js index 1340d48..776e25a 100644 --- a/app/static/js/constants.js +++ b/app/static/js/constants.js @@ -1,19 +1,6 @@ -// Default catalog configurations -export const defaultCatalogs = [ - { 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.' }, -]; +// Default catalog configurations (loaded from backend via window.DEFAULT_CATALOGS) +export const defaultCatalogs = window.DEFAULT_CATALOGS || []; -// Genre Constants -export const MOVIE_GENRES = [ - { id: '28', name: 'Action' }, { id: '12', name: 'Adventure' }, { id: '16', name: 'Animation' }, { id: '35', name: 'Comedy' }, { id: '80', name: 'Crime' }, { id: '99', name: 'Documentary' }, { id: '18', name: 'Drama' }, { id: '10751', name: 'Family' }, { id: '14', name: 'Fantasy' }, { id: '36', name: 'History' }, { id: '27', name: 'Horror' }, { id: '10402', name: 'Music' }, { id: '9648', name: 'Mystery' }, { id: '10749', name: 'Romance' }, { id: '878', name: 'Science Fiction' }, { id: '10770', name: 'TV Movie' }, { id: '53', name: 'Thriller' }, { id: '10752', name: 'War' }, { id: '37', name: 'Western' } -]; - -export const SERIES_GENRES = [ - { id: '10759', name: 'Action & Adventure' }, { id: '16', name: 'Animation' }, { id: '35', name: 'Comedy' }, { id: '80', name: 'Crime' }, { id: '99', name: 'Documentary' }, { id: '18', name: 'Drama' }, { id: '10751', name: 'Family' }, { id: '10762', name: 'Kids' }, { id: '9648', name: 'Mystery' }, { id: '10763', name: 'News' }, { id: '10764', name: 'Reality' }, { id: '10765', name: 'Sci-Fi & Fantasy' }, { id: '10766', name: 'Soap' }, { id: '10767', name: 'Talk' }, { id: '10768', name: 'War & Politics' }, { id: '37', name: 'Western' } -]; +// Genre Constants (loaded from backend via window.MOVIE_GENRES and window.SERIES_GENRES) +export const MOVIE_GENRES = window.MOVIE_GENRES || []; +export const SERIES_GENRES = window.SERIES_GENRES || []; diff --git a/app/static/js/modules/auth.js b/app/static/js/modules/auth.js index a62d796..13e0c1d 100644 --- a/app/static/js/modules/auth.js +++ b/app/static/js/modules/auth.js @@ -14,6 +14,55 @@ let getCatalogs = null; let renderCatalogList = null; let resetApp = null; +// LocalStorage keys +const STORAGE_KEY = 'watchly_auth'; +const EXPIRY_DAYS = 30; + +// LocalStorage helper functions +function saveAuthToStorage(authData) { + try { + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + EXPIRY_DAYS); + const data = { + ...authData, + expiresAt: expiryDate.getTime() + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch (e) { + console.warn('Failed to save auth to localStorage:', e); + } +} + +function getAuthFromStorage() { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return null; + + const data = JSON.parse(stored); + const now = Date.now(); + + // Check if expired + if (data.expiresAt && data.expiresAt < now) { + clearAuthFromStorage(); + return null; + } + + return data; + } catch (e) { + console.warn('Failed to read auth from localStorage:', e); + clearAuthFromStorage(); + return null; + } +} + +function clearAuthFromStorage() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (e) { + console.warn('Failed to clear auth from localStorage:', e); + } +} + export function initializeAuth(domElements, catalogState) { stremioLoginBtn = domElements.stremioLoginBtn; stremioLoginText = domElements.stremioLoginText; @@ -25,10 +74,131 @@ export function initializeAuth(domElements, catalogState) { renderCatalogList = catalogState.renderCatalogList; resetApp = catalogState.resetApp; + // Initialize logout buttons + initializeLoginStatusLogoutButton(); + initializeUserProfileDropdown(); + + // Try to auto-login from localStorage + attemptAutoLogin(); + initializeStremioLogin(); initializeEmailPasswordLogin(); } +// Initialize user profile dropdown +function initializeUserProfileDropdown() { + const trigger = document.getElementById('user-profile-trigger'); + const dropdown = document.getElementById('user-profile-dropdown'); + const logoutBtn = document.getElementById('user-profile-logout-btn'); + const chevron = document.getElementById('user-profile-chevron'); + + if (!trigger || !dropdown || !logoutBtn) return; + + // Toggle dropdown on trigger click + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = !dropdown.classList.contains('hidden'); + if (isOpen) { + closeDropdown(); + } else { + openDropdown(); + } + }); + + // Handle logout button click + logoutBtn.addEventListener('click', () => { + closeDropdown(); + // Close mobile nav if open + const sidebar = document.getElementById('mainSidebar'); + const backdrop = document.getElementById('mobileNavBackdrop'); + if (sidebar && backdrop) { + sidebar.classList.remove('translate-x-0'); + sidebar.classList.add('-translate-x-full'); + backdrop.classList.add('hidden'); + document.body.classList.remove('overflow-hidden'); + const mobileToggle = document.getElementById('mobileNavToggle'); + if (mobileToggle) { + mobileToggle.classList.remove('is-active'); + mobileToggle.setAttribute('aria-expanded', 'false'); + mobileToggle.setAttribute('aria-label', 'Open navigation'); + } + } + if (resetApp) resetApp(); + }); + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!trigger.contains(e.target) && !dropdown.contains(e.target)) { + closeDropdown(); + } + }); + + function openDropdown() { + dropdown.classList.remove('hidden'); + if (chevron) { + chevron.style.transform = 'rotate(180deg)'; + } + } + + function closeDropdown() { + dropdown.classList.add('hidden'); + if (chevron) { + chevron.style.transform = 'rotate(0deg)'; + } + } +} + +// Initialize logout button in login status section +function initializeLoginStatusLogoutButton() { + const logoutBtn = document.getElementById('loginStatusLogoutBtn'); + if (!logoutBtn) return; + + logoutBtn.addEventListener('click', () => { + if (resetApp) resetApp(); + }); +} + +// Attempt to auto-login from stored credentials +async function attemptAutoLogin() { + // Don't auto-login if there's an auth key in URL (let URL-based login handle it) + const urlParams = new URLSearchParams(window.location.search); + const urlAuthKey = urlParams.get('key') || urlParams.get('authKey'); + if (urlAuthKey) return; + + const storedAuth = getAuthFromStorage(); + if (!storedAuth) return; + + try { + // If we have an auth key, use it + if (storedAuth.authKey) { + setStremioLoggedInState(storedAuth.authKey); + await fetchStremioIdentity(storedAuth.authKey); + unlockNavigation(); + switchSection('config'); + return; + } + + // If we have email/password, use them + if (storedAuth.email && storedAuth.password) { + // Pre-fill inputs + if (emailInput) emailInput.value = storedAuth.email; + if (passwordInput) passwordInput.value = storedAuth.password; + + // Try to login + await fetchStremioIdentity(null); + setStremioLoggedInState(''); + unlockNavigation(); + switchSection('config'); + return; + } + } catch (error) { + // Auto-login failed, clear stored auth + console.warn('Auto-login failed:', error); + clearAuthFromStorage(); + if (resetApp) resetApp(); + } +} + // Stremio Login Logic async function initializeStremioLogin() { const urlParams = new URLSearchParams(window.location.search); @@ -40,10 +210,13 @@ async function initializeStremioLogin() { try { await fetchStremioIdentity(authKey); + // Save auth key to localStorage for persistent login + saveAuthToStorage({ authKey }); unlockNavigation(); switchSection('config'); } catch (error) { showToast(error.message, "error"); + clearAuthFromStorage(); if (resetApp) resetApp(); return; } @@ -180,6 +353,8 @@ function initializeEmailPasswordLogin() { setEmailPwdLoading(true); // Reuse the shared identity handler to populate settings if account exists await fetchStremioIdentity(null); + // Save email/password to localStorage for persistent login + saveAuthToStorage({ email, password: pwd }); // Mark as logged-in (disables inputs and flips button to Logout) setStremioLoggedInState(''); // Proceed to config @@ -187,6 +362,7 @@ function initializeEmailPasswordLogin() { switchSection('config'); } catch (e) { showEmailPwdError(e.message || 'Login failed'); + clearAuthFromStorage(); // Preserve email, clear only password if (passwordInput) passwordInput.value = ''; } finally { @@ -257,6 +433,9 @@ export function setStremioLoggedOutState() { const authKeyInput = document.getElementById('authKey'); if (authKeyInput) authKeyInput.value = ''; + // Clear stored auth credentials + clearAuthFromStorage(); + // Hide user profile hideUserProfile(); @@ -284,11 +463,16 @@ export function setStremioLoggedOutState() { // User Profile Functions function showUserProfile(email) { - const userProfile = document.getElementById('user-profile'); + const userProfileWrapper = document.getElementById('user-profile-dropdown-wrapper'); const userEmail = document.getElementById('user-email'); const userAvatar = document.getElementById('user-avatar'); - if (!userProfile || !userEmail || !userAvatar) return; + // Login status section elements + const loginStatusSection = document.getElementById('loginStatusSection'); + const loginStatusEmail = document.getElementById('loginStatusEmail'); + const loginStatusAvatar = document.getElementById('loginStatusAvatar'); + + if (!userProfileWrapper || !userEmail || !userAvatar) return; // Set email userEmail.textContent = email; @@ -297,15 +481,47 @@ function showUserProfile(email) { const initials = getInitialsFromEmail(email); userAvatar.textContent = initials; - // Show the profile - userProfile.classList.remove('hidden'); + // Show the profile dropdown wrapper + userProfileWrapper.classList.remove('hidden'); + + // Show login status section and update it + if (loginStatusSection && loginStatusEmail && loginStatusAvatar) { + loginStatusEmail.textContent = email; + loginStatusAvatar.textContent = initials; + loginStatusSection.classList.remove('hidden'); + } + + // Hide the login form when logged in + const loginFormCard = document.getElementById('loginFormCard'); + if (loginFormCard) loginFormCard.classList.add('hidden'); } function hideUserProfile() { - const userProfile = document.getElementById('user-profile'); - if (userProfile) { - userProfile.classList.add('hidden'); + const userProfileWrapper = document.getElementById('user-profile-dropdown-wrapper'); + const dropdown = document.getElementById('user-profile-dropdown'); + const loginStatusSection = document.getElementById('loginStatusSection'); + + if (userProfileWrapper) { + userProfileWrapper.classList.add('hidden'); + } + + // Close dropdown if open + if (dropdown) { + dropdown.classList.add('hidden'); + const chevron = document.getElementById('user-profile-chevron'); + if (chevron) { + chevron.style.transform = 'rotate(0deg)'; + } + } + + // Hide login status section + if (loginStatusSection) { + loginStatusSection.classList.add('hidden'); } + + // Show the login form when logged out + const loginFormCard = document.getElementById('loginFormCard'); + if (loginFormCard) loginFormCard.classList.remove('hidden'); } function getInitialsFromEmail(email) { diff --git a/app/static/js/modules/catalog.js b/app/static/js/modules/catalog.js index 01aaeed..9ae3205 100644 --- a/app/static/js/modules/catalog.js +++ b/app/static/js/modules/catalog.js @@ -62,31 +62,69 @@ function createCatalogItem(cat, index) { let activeMode = 'both'; if (enabledMovie && !enabledSeries) activeMode = 'movie'; else if (!enabledMovie && enabledSeries) activeMode = 'series'; + // Initialize display_at_home and shuffle if not present (for backward compatibility) + if (cat.display_at_home === undefined) cat.display_at_home = true; + if (cat.shuffle === undefined) cat.shuffle = false; + item.innerHTML = ` -
-
- - -
-
-
- ${escapeHtml(cat.name)} - - ${isRenamable ? `` : ''} +
+
-
${escapeHtml(cat.description || '')}
- -
- -
+
+
+
+ ${escapeHtml(cat.name)} + +
+
+ ${isRenamable ? `` : ''} + + + +
+
+
+
${escapeHtml(cat.description || '')}
+
+
- + + `; - nameContainer.appendChild(editActions); + nameInputWrapper.appendChild(editActions); const saveBtn = editActions.querySelector('.save'); const cancelBtn = editActions.querySelector('.cancel'); - function enableEdit() { - nameContainer.classList.add('editing'); - nameText.classList.add('hidden'); - nameInput.classList.remove('hidden'); - editActions.classList.remove('hidden'); editActions.classList.add('flex'); - if (renameBtn) renameBtn.classList.add('invisible'); - nameInput.focus(); - } function saveEdit() { const newName = nameInput.value.trim(); if (newName) { cat.name = newName; nameText.textContent = newName; nameInput.value = newName; } @@ -179,12 +326,11 @@ function setupRenameLogic(item, cat) { function cancelEdit() { nameInput.value = cat.name; closeEdit(); } function closeEdit() { nameContainer.classList.remove('editing'); - nameInput.classList.add('hidden'); + nameInputWrapper.classList.add('hidden'); editActions.classList.add('hidden'); editActions.classList.remove('flex'); nameText.classList.remove('hidden'); - if (renameBtn) renameBtn.classList.remove('invisible'); } - if (renameBtn) renameBtn.addEventListener('click', (e) => { e.preventDefault(); enableEdit(); }); + saveBtn.addEventListener('click', (e) => { e.preventDefault(); saveEdit(); }); cancelBtn.addEventListener('click', (e) => { e.preventDefault(); cancelEdit(); }); nameInput.addEventListener('keydown', (e) => { @@ -192,5 +338,3 @@ function setupRenameLogic(item, cat) { else if (e.key === 'Escape') { cancelEdit(); } }); } - -// catalogs is exported via getCatalogs() to maintain proper state management diff --git a/app/static/js/modules/form.js b/app/static/js/modules/form.js index 619a6ae..f8dcb5c 100644 --- a/app/static/js/modules/form.js +++ b/app/static/js/modules/form.js @@ -51,43 +51,44 @@ async function initializeFormSubmission() { const catalogsToSend = []; const catalogs = getCatalogs ? getCatalogs() : []; - document.querySelectorAll(".catalog-item .switch input[type='checkbox']").forEach(toggle => { - const catalogId = toggle.dataset.catalogId; - const enabled = toggle.checked; - const originalCatalog = catalogs.find(c => c.id === catalogId); - if (originalCatalog) { - // 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; - let enabledSeries = true; - - if (activeBtn) { - const mode = activeBtn.dataset.mode; - if (mode === 'movie') { - enabledMovie = true; - enabledSeries = false; - } else if (mode === 'series') { - enabledMovie = false; - enabledSeries = true; - } else { - // 'both' or default - enabledMovie = true; - enabledSeries = true; - } + // Get enabled state from catalog objects (updated by visibility button) + catalogs.forEach(originalCatalog => { + const catalogId = originalCatalog.id; + const enabled = originalCatalog.enabled !== false; + + // 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; + let enabledSeries = true; + + if (activeBtn) { + const mode = activeBtn.dataset.mode; + if (mode === 'movie') { + enabledMovie = true; + enabledSeries = false; + } else if (mode === 'series') { + enabledMovie = false; + enabledSeries = true; } else { - // Fallback to catalog state - enabledMovie = originalCatalog.enabledMovie !== false; - enabledSeries = originalCatalog.enabledSeries !== false; + // 'both' or default + enabledMovie = true; + enabledSeries = true; } - - catalogsToSend.push({ - id: catalogId, - name: originalCatalog.name, - enabled: enabled, - enabled_movie: enabledMovie, - enabled_series: enabledSeries, - }); + } else { + // Fallback to catalog state + enabledMovie = originalCatalog.enabledMovie !== false; + enabledSeries = originalCatalog.enabledSeries !== false; } + + catalogsToSend.push({ + id: catalogId, + name: originalCatalog.name, + enabled: enabled, + enabled_movie: enabledMovie, + enabled_series: enabledSeries, + display_at_home: originalCatalog.display_at_home !== false, // Default to true if not set + shuffle: originalCatalog.shuffle === true, // Default to false if not set + }); }); // Validation diff --git a/app/templates/base.html b/app/templates/base.html index b476b33..b496da9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -211,11 +211,25 @@ input.stepper-input[type=number] { -moz-appearance: textfield; } + + /* User profile dropdown chevron transition */ + #user-profile-chevron { + transition: transform 200ms ease; + } {% block content %}{% endblock %} + + diff --git a/app/templates/components/section_login.html b/app/templates/components/section_login.html index 99f45f7..1443106 100644 --- a/app/templates/components/section_login.html +++ b/app/templates/components/section_login.html @@ -4,7 +4,35 @@

Connect to Stremio

Log in to your Stremio account to enable Watchly to read your library.

-
+ + + + +
+ + +
+
+ +