Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/api/endpoints/catalogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 14 additions & 0 deletions app/core/app.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from contextlib import asynccontextmanager
from pathlib import Path

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -95,13 +99,23 @@ 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,
app_version=__version__,
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")

Expand Down
3 changes: 3 additions & 0 deletions app/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}]
51 changes: 51 additions & 0 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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",
Expand All @@ -27,53 +44,87 @@ def get_default_settings() -> UserSettings:
enabled=True,
enabled_movie=True,
enabled_series=True,
display_at_home=True,
shuffle=False,
),
CatalogConfig(
id="watchly.loved",
name="More Like",
enabled=True,
enabled_movie=True,
enabled_series=True,
display_at_home=True,
shuffle=False,
),
CatalogConfig(
id="watchly.watched",
name="Because you watched",
enabled=True,
enabled_movie=True,
enabled_series=True,
display_at_home=True,
shuffle=False,
),
CatalogConfig(
id="watchly.theme",
name="Genre & Keyword Catalogs",
enabled=True,
enabled_movie=True,
enabled_series=True,
display_at_home=True,
shuffle=False,
),
CatalogConfig(
id="watchly.creators",
name="From your favourite Creators",
enabled=False,
enabled_movie=True,
enabled_series=True,
display_at_home=True,
shuffle=False,
),
CatalogConfig(
id="watchly.all.loved",
name="Based on what you loved",
enabled=False,
enabled_movie=True,
enabled_series=True,
display_at_home=True,
shuffle=False,
),
CatalogConfig(
id="watchly.liked.all",
name="Based on what you liked",
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
Expand Down
25 changes: 19 additions & 6 deletions app/services/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]:
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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)

Expand Down Expand Up @@ -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 <Watched Item>
if watched_config and watched_config.enabled and is_type_enabled(watched_config, content_type):
Expand All @@ -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)
)
16 changes: 9 additions & 7 deletions app/services/profile/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
4 changes: 2 additions & 2 deletions app/services/profile/sampling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions app/services/profile/scorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
class ProfileScorer:
"""
Scores items against taste profile using unified function.

Design principle: Same function everywhere, no special cases.
"""

@staticmethod
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 0 additions & 2 deletions app/services/recommendation/all_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading