Skip to content

Commit 4c84d67

Browse files
feat: implement new recommendation services and profile system (#82)
* chore: upgrade Python version to 3.12 and update dependencies * feat: implement new recommendation services and profile system * refactor: streamline catalog fetching by integrating CatalogService and simplifying endpoint logic * refactor: enhance recommendation services by consolidating utility functions and improving filtering logic * feat: add default item limits and concurrency settings for recommendations * feat: add minimum rating thresholds for theme-based recommendations and streamline creator recommendation logic * feat: implement final score calculation for recommendations, enhancing scoring logic with quality adjustments
1 parent b53fc05 commit 4c84d67

40 files changed

+3067
-2196
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@ logs/
4747
*/ipynb_checkpoints/
4848
*.ipynb
4949
.vercel
50+
migration.py

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.10
1+
3.12

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.11-slim
1+
FROM python:3.12-slim
22

33
WORKDIR /app
44

app/api/endpoints/catalogs.py

Lines changed: 15 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,14 @@
1-
import re
2-
31
from fastapi import APIRouter, HTTPException, Response
42
from loguru import logger
53

6-
from app.api.endpoints.manifest import get_config_id
7-
from app.core.config import settings
84
from app.core.security import redact_token
9-
from app.core.settings import UserSettings, get_default_settings
10-
from app.services.catalog_updater import catalog_updater
11-
from app.services.recommendation.engine import RecommendationEngine
12-
from app.services.stremio.service import StremioBundle
13-
from app.services.token_store import token_store
14-
15-
MAX_RESULTS = 50
16-
DEFAULT_MIN_ITEMS = 20
17-
DEFAULT_MAX_ITEMS = 32
18-
SOURCE_ITEMS_LIMIT = 10
5+
from app.services.recommendation.catalog_service import CatalogService
196

207
router = APIRouter()
218

9+
# Initialize catalog service (singleton)
10+
_catalog_service = CatalogService()
11+
2212

2313
def _clean_meta(meta: dict) -> dict:
2414
"""Return a sanitized Stremio meta object without internal fields.
@@ -49,164 +39,27 @@ def _clean_meta(meta: dict) -> dict:
4939

5040
@router.get("/{token}/catalog/{type}/{id}.json")
5141
async def get_catalog(type: str, id: str, response: Response, token: str):
52-
if not token:
53-
raise HTTPException(
54-
status_code=400,
55-
detail="Missing credentials token. Please open Watchly from a configured manifest URL.",
56-
)
57-
58-
if type not in ["movie", "series"]:
59-
logger.warning(f"Invalid type: {type}")
60-
raise HTTPException(status_code=400, detail="Invalid type. Use 'movie' or 'series'")
61-
62-
# Supported IDs now include dynamic themes and item-based rows
63-
if id != "watchly.rec" and not any(
64-
id.startswith(p)
65-
for p in (
66-
"tt",
67-
"watchly.theme.",
68-
"watchly.item.",
69-
"watchly.loved.",
70-
"watchly.watched.",
71-
)
72-
):
73-
logger.warning(f"Invalid id: {id}")
74-
raise HTTPException(
75-
status_code=400,
76-
detail=( #
77-
"Invalid id. Supported: 'watchly.rec', 'watchly.theme.<params>', 'watchly.item.<id>', or"
78-
" specific item IDs."
79-
),
80-
)
81-
82-
logger.info(f"[{redact_token(token)}] Fetching catalog for {type} with id {id}")
83-
84-
credentials = await token_store.get_user_data(token)
85-
if not credentials:
86-
raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.")
87-
88-
# Trigger lazy update if needed
89-
if settings.AUTO_UPDATE_CATALOGS:
90-
await catalog_updater.trigger_update(token, credentials)
42+
"""
43+
Get catalog recommendations.
9144
92-
bundle = StremioBundle()
45+
This endpoint delegates all logic to CatalogService facade.
46+
"""
9347
try:
94-
# 1. Resolve Auth Key (with potential fallback to login)
95-
auth_key = credentials.get("authKey")
96-
email = credentials.get("email")
97-
password = credentials.get("password")
98-
99-
is_valid = False
100-
if auth_key:
101-
try:
102-
await bundle.auth.get_user_info(auth_key)
103-
is_valid = True
104-
except Exception:
105-
pass
48+
# Delegate to catalog service facade
49+
recommendations, headers = await _catalog_service.get_catalog(token, type, id)
10650

107-
if not is_valid and email and password:
108-
try:
109-
auth_key = await bundle.auth.login(email, password)
110-
credentials["authKey"] = auth_key
111-
await token_store.update_user_data(token, credentials)
112-
except Exception as e:
113-
logger.error(f"Failed to refresh auth key during catalog fetch: {e}")
51+
# Set response headers
52+
for key, value in headers.items():
53+
response.headers[key] = value
11454

115-
if not auth_key:
116-
raise HTTPException(status_code=401, detail="Stremio session expired. Please reconfigure.")
117-
118-
# 2. Extract settings from credentials
119-
settings_dict = credentials.get("settings", {})
120-
user_settings = UserSettings(**settings_dict) if settings_dict else get_default_settings()
121-
language = user_settings.language if user_settings else "en-US"
122-
123-
# 3. Fetch library once per request and reuse across recommendation paths
124-
library_items = await bundle.library.get_library_items(auth_key)
125-
engine = RecommendationEngine(
126-
stremio_service=bundle,
127-
language=language,
128-
user_settings=user_settings,
129-
token=token,
130-
library_data=library_items,
131-
)
132-
133-
# Resolve per-catalog limits (min/max)
134-
def _get_limits() -> tuple[int, int]:
135-
try:
136-
cfg_id = get_config_id({"id": id})
137-
except Exception:
138-
cfg_id = id
139-
try:
140-
cfg = next((c for c in user_settings.catalogs if c.id == cfg_id), None)
141-
if cfg and hasattr(cfg, "min_items") and hasattr(cfg, "max_items"):
142-
return int(cfg.min_items or DEFAULT_MIN_ITEMS), int(cfg.max_items or DEFAULT_MAX_ITEMS)
143-
except Exception:
144-
pass
145-
return DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
146-
147-
min_items, max_items = _get_limits()
148-
# Enforce caps: min_items <= 20, max_items <= 32 and max >= min
149-
try:
150-
min_items = max(1, min(DEFAULT_MIN_ITEMS, int(min_items)))
151-
max_items = max(min_items, min(DEFAULT_MAX_ITEMS, int(max_items)))
152-
except (ValueError, TypeError):
153-
logger.warning(
154-
"Invalid min/max items values. Falling back to defaults. "
155-
f"min_items={min_items}, max_items={max_items}"
156-
)
157-
min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
158-
159-
# Handle item-based recommendations
160-
if id.startswith("tt"):
161-
engine.per_item_limit = max_items
162-
recommendations = await engine.get_recommendations_for_item(item_id=id, media_type=type)
163-
if len(recommendations) < min_items:
164-
recommendations = await engine.pad_to_min(type, recommendations, min_items)
165-
logger.info(f"Found {len(recommendations)} recommendations for {id}")
166-
167-
elif any(
168-
id.startswith(p)
169-
for p in (
170-
"watchly.item.",
171-
"watchly.loved.",
172-
"watchly.watched.",
173-
)
174-
):
175-
# Extract actual item ID (tt... or tmdb:...)
176-
item_id = re.sub(r"^watchly\.(item|loved|watched)\.", "", id)
177-
engine.per_item_limit = max_items
178-
recommendations = await engine.get_recommendations_for_item(item_id=item_id, media_type=type)
179-
if len(recommendations) < min_items:
180-
recommendations = await engine.pad_to_min(type, recommendations, min_items)
181-
logger.info(f"Found {len(recommendations)} recommendations for item {item_id}")
182-
183-
elif id.startswith("watchly.theme."):
184-
recommendations = await engine.get_recommendations_for_theme(
185-
theme_id=id, content_type=type, limit=max_items
186-
)
187-
if len(recommendations) < min_items:
188-
recommendations = await engine.pad_to_min(type, recommendations, min_items)
189-
logger.info(f"Found {len(recommendations)} recommendations for theme {id}")
190-
191-
else:
192-
recommendations = await engine.get_recommendations(
193-
content_type=type, source_items_limit=SOURCE_ITEMS_LIMIT, max_results=max_items
194-
)
195-
if len(recommendations) < min_items:
196-
recommendations = await engine.pad_to_min(type, recommendations, min_items)
197-
logger.info(f"Found {len(recommendations)} recommendations for {type}")
198-
199-
logger.info(f"Returning {len(recommendations)} items for {type}")
200-
response.headers["Cache-Control"] = "public, max-age=21600" # 6 hours
55+
# Clean and format metadata
20156
cleaned = [_clean_meta(m) for m in recommendations]
202-
# remove none values
20357
cleaned = [m for m in cleaned if m is not None]
58+
20459
return {"metas": cleaned}
20560

20661
except HTTPException:
20762
raise
20863
except Exception as e:
20964
logger.exception(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}")
21065
raise HTTPException(status_code=500, detail=str(e))
211-
finally:
212-
await bundle.close()

app/api/endpoints/manifest.py

Lines changed: 29 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,57 +14,52 @@
1414
router = APIRouter()
1515

1616

17+
def get_catalogs_from_config(
18+
user_settings: UserSettings, cat_id: str, default_name: str, default_movie: bool, default_series: bool
19+
):
20+
catalogs = []
21+
config = next((c for c in user_settings.catalogs if c.id == cat_id), None)
22+
if not config or config.enabled:
23+
name = config.name if config and config.name else default_name
24+
enabled_movie = getattr(config, "enabled_movie", default_movie) if config else default_movie
25+
enabled_series = getattr(config, "enabled_series", default_series) if config else default_series
26+
27+
if enabled_movie:
28+
catalogs.append({"type": "movie", "id": cat_id, "name": name, "extra": []})
29+
if enabled_series:
30+
catalogs.append({"type": "series", "id": cat_id, "name": name, "extra": []})
31+
return catalogs
32+
33+
1734
def get_base_manifest(user_settings: UserSettings | None = None):
18-
# Default catalog config
19-
rec_config = None
20-
if user_settings:
21-
# Find config for 'recommended'
22-
rec_config = next((c for c in user_settings.catalogs if c.id == "watchly.rec"), None)
35+
catalogs = []
2336

24-
# If disabled explicitly, don't include it.
25-
# If not configured (None), default to enabled.
26-
if rec_config and not rec_config.enabled:
27-
catalogs = []
37+
if user_settings:
38+
catalogs.extend(get_catalogs_from_config(user_settings, "watchly.rec", "Top Picks for You", True, True))
39+
catalogs.extend(
40+
get_catalogs_from_config(user_settings, "watchly.creators", "From your favourite Creators", False, False)
41+
)
2842
else:
29-
name = rec_config.name if rec_config and rec_config.name else "Top Picks for You"
30-
enabled_movie = getattr(rec_config, "enabled_movie", True) if rec_config else True
31-
enabled_series = getattr(rec_config, "enabled_series", True) if rec_config else True
32-
43+
# Default: empty catalogs
3344
catalogs = []
34-
if enabled_movie:
35-
catalogs.append(
36-
{
37-
"type": "movie",
38-
"id": "watchly.rec",
39-
"name": name,
40-
"extra": [],
41-
}
42-
)
43-
if enabled_series:
44-
catalogs.append(
45-
{
46-
"type": "series",
47-
"id": "watchly.rec",
48-
"name": name,
49-
"extra": [],
50-
}
51-
)
5245

5346
return {
5447
"id": settings.ADDON_ID,
5548
"version": __version__,
5649
"name": settings.ADDON_NAME,
5750
"description": "Movie and series recommendations based on your Stremio library",
5851
"logo": "https://raw.githubusercontent.com/TimilsinaBimal/Watchly/refs/heads/main/app/static/logo.png",
59-
"background": "https://raw.githubusercontent.com/TimilsinaBimal/Watchly/refs/heads/main/app/static/cover.png",
52+
"background": ("https://raw.githubusercontent.com/TimilsinaBimal/Watchly/refs/heads/main/app/static/cover.png"),
6053
"resources": ["catalog"],
6154
"types": ["movie", "series"],
6255
"idPrefixes": ["tt"],
6356
"catalogs": catalogs,
6457
"behaviorHints": {"configurable": True, "configurationRequired": False},
6558
"stremioAddonsConfig": {
6659
"issuer": "https://stremio-addons.net",
67-
"signature": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0..ycLGL5WUjggv7PxKPqMLYQ.Y_cD-8wqoXqENdXbFmR1-Si39NtqBlsxEDdrEO0deciilBsWWAlPIglx85XFE4ScSfSqzNxrCZUjHjWWIb2LdcFuvE1RVBrFsUBXgbs5eQknnEL617pFtCWNh0bi37Xv.zYhJ87ZqcYZMRfxLY0bSGQ", # noqa
60+
"signature": (
61+
"eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0..WSrhzzlj1TuDycD6QoVLuA.Dzmxzr4y83uqQF15r4tC1bB9-vtZRh1Rvy4BqgDYxu91c2esiJuov9KnnI_cboQCgZS7hjwnIqRSlQ-jEyGwXHHRerh9QklyfdxpXqNUyBgTWFzDOVdVvDYJeM_tGMmR.sezAChlWGV7lNS-t9HWB6A" # noqa
62+
),
6863
},
6964
}
7065

@@ -79,8 +74,7 @@ async def build_dynamic_catalogs(bundle: StremioBundle, auth_key: str, user_sett
7974

8075

8176
async def _manifest_handler(response: Response, token: str):
82-
response.headers["Cache-Control"] = "public, max-age=300" # 5 minutes
83-
77+
# response.headers["Cache-Control"] = "public, max-age=300" # 5 minutes
8478
if not token:
8579
raise HTTPException(status_code=401, detail="Missing token. Please reconfigure the addon.")
8680

app/core/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
11
RECOMMENDATIONS_CATALOG_NAME: str = "Top Picks For You"
2+
DEFAULT_MIN_ITEMS: int = 20
3+
DEFAULT_MAX_ITEMS: int = 32
4+
5+
DEFAULT_CONCURRENCY_LIMIT: int = 30
6+
7+
8+
DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_MOVIE: float = 7.2
9+
DEFAULT_MINIMUM_RATING_FOR_THEME_BASED_TV: float = 6.8

app/core/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ def get_default_settings() -> UserSettings:
3737
enabled_movie=True,
3838
enabled_series=True,
3939
),
40+
CatalogConfig(
41+
id="watchly.creators",
42+
name="From your favourite Creators",
43+
enabled=True,
44+
enabled_movie=True,
45+
enabled_series=True,
46+
),
4047
],
4148
)
4249

app/core/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.4.3"
1+
__version__ = "1.4.4"

0 commit comments

Comments
 (0)