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
38 changes: 16 additions & 22 deletions app/api/endpoints/manifest.py
Original file line number Diff line number Diff line change
@@ -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__,
Expand All @@ -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",
Expand All @@ -53,16 +41,22 @@ 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,
)
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.")
Expand All @@ -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 = []
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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)
36 changes: 32 additions & 4 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
),
Expand Down
2 changes: 1 addition & 1 deletion app/core/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.4.5"
__version__ = "1.5.0"
19 changes: 19 additions & 0 deletions app/services/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down
14 changes: 0 additions & 14 deletions app/services/catalog_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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


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