Skip to content

Commit 213531a

Browse files
feat: Add recency preference and genre diversification to recommendation logic (#44)
* feat: Enhance TMDBService with trending and top-rated content retrieval * feat: Add recency preference and genre diversification to recommendation logic * feat: Refactor TMDBService usage to support language preference across services * feat: Refactor library item fetching and caching for improved performance and consistency * feat: Implement caching for language retrieval and refactor auth key encryption * feat: Add middleware for Redis call tracking and enhance token store with call counting * chore: bump version to v1.1.0
1 parent f29ac4e commit 213531a

File tree

17 files changed

+1409
-381
lines changed

17 files changed

+1409
-381
lines changed

app/api/endpoints/catalogs.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from app.services.token_store import token_store
1212

1313
MAX_RESULTS = 50
14-
SOURCE_ITEMS_LIMIT = 15
14+
SOURCE_ITEMS_LIMIT = 10
1515

1616
router = APIRouter()
1717

@@ -54,8 +54,14 @@ async def get_catalog(type: str, id: str, response: Response, token: str):
5454

5555
# Create services with credentials
5656
stremio_service = StremioService(auth_key=credentials.get("authKey"))
57+
# Fetch library once per request and reuse across recommendation paths
58+
library_items = await stremio_service.get_library_items()
5759
recommendation_service = RecommendationService(
58-
stremio_service=stremio_service, language=language, user_settings=user_settings
60+
stremio_service=stremio_service,
61+
language=language,
62+
user_settings=user_settings,
63+
token=token,
64+
library_data=library_items,
5965
)
6066

6167
# Handle item-based recommendations
@@ -83,7 +89,9 @@ async def get_catalog(type: str, id: str, response: Response, token: str):
8389

8490
logger.info(f"Returning {len(recommendations)} items for {type}")
8591
# Cache catalog responses for 4 hours
86-
response.headers["Cache-Control"] = "public, max-age=14400" if len(recommendations) > 0 else "no-cache"
92+
response.headers["Cache-Control"] = (
93+
"public, max-age=14400" if len(recommendations) > 0 else "public, max-age=7200"
94+
)
8795
return {"metas": recommendations}
8896

8997
except HTTPException:

app/api/endpoints/manifest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ async def fetch_catalogs(token: str):
7171

7272
# Note: get_library_items is expensive, but we need it to determine *which* genre catalogs to show.
7373
library_items = await stremio_service.get_library_items()
74-
dynamic_catalog_service = DynamicCatalogService(stremio_service=stremio_service)
74+
dynamic_catalog_service = DynamicCatalogService(stremio_service=stremio_service, language=user_settings.language)
7575

7676
# Base catalogs are already in manifest, these are *extra* dynamic ones
7777
# Pass user_settings to filter/rename
@@ -96,7 +96,7 @@ def get_config_id(catalog) -> str | None:
9696

9797

9898
async def _manifest_handler(response: Response, token: str):
99-
response.headers["Cache-Control"] = "no-cache"
99+
response.headers["Cache-Control"] = "public, max-age=7200"
100100

101101
if not token:
102102
raise HTTPException(status_code=401, detail="Missing token. Please reconfigure the addon.")

app/api/endpoints/meta.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
1+
from async_lru import alru_cache
12
from fastapi import APIRouter, HTTPException
23
from loguru import logger
34

4-
from app.services.tmdb_service import TMDBService
5+
from app.services.tmdb_service import get_tmdb_service
56

67
router = APIRouter()
78

89

10+
@alru_cache(maxsize=1, ttl=24 * 60 * 60)
11+
async def _cached_languages():
12+
tmdb = get_tmdb_service()
13+
return await tmdb._make_request("/configuration/languages")
14+
15+
916
@router.get("/api/languages")
1017
async def get_languages():
1118
"""
1219
Proxy endpoint to fetch languages from TMDB.
1320
"""
14-
tmdb_service = TMDBService()
1521
try:
16-
languages = await tmdb_service._make_request("/configuration/languages")
22+
languages = await _cached_languages()
1723
if not languages:
1824
return []
1925
return languages
2026
except Exception as e:
2127
logger.error(f"Failed to fetch languages: {e}")
2228
raise HTTPException(status_code=502, detail="Failed to fetch languages from TMDB")
2329
finally:
24-
await tmdb_service.close()
30+
# shared client: no explicit close
31+
pass

app/core/app.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
from contextlib import asynccontextmanager
44
from pathlib import Path
55

6-
from fastapi import FastAPI
6+
from fastapi import FastAPI, Request
77
from fastapi.middleware.cors import CORSMiddleware
88
from fastapi.responses import HTMLResponse
99
from fastapi.staticfiles import StaticFiles
1010
from loguru import logger
1111

1212
from app.api.main import api_router
1313
from app.services.catalog_updater import BackgroundCatalogUpdater
14+
from app.services.token_store import token_store
1415
from app.startup.migration import migrate_tokens
1516

1617
from .config import settings
@@ -82,6 +83,23 @@ def _on_done(t: asyncio.Task):
8283
allow_headers=["*"],
8384
)
8485

86+
87+
# Middleware to track per-request Redis calls and attach as response header for diagnostics
88+
@app.middleware("http")
89+
async def redis_calls_middleware(request: Request, call_next):
90+
try:
91+
token_store.reset_call_counter()
92+
except Exception:
93+
pass
94+
response = await call_next(request)
95+
try:
96+
count = token_store.get_call_count()
97+
response.headers["X-Redis-Calls"] = str(count)
98+
except Exception:
99+
pass
100+
return response
101+
102+
85103
# Serve static files
86104
# Static directory is at project root (3 levels up from app/core/app.py)
87105
# app/core/app.py -> app/core -> app -> root

app/core/settings.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel
1+
from pydantic import BaseModel, Field
22

33

44
class CatalogConfig(BaseModel):
@@ -11,8 +11,8 @@ class UserSettings(BaseModel):
1111
catalogs: list[CatalogConfig]
1212
language: str = "en-US"
1313
rpdb_key: str | None = None
14-
excluded_movie_genres: list[str] = []
15-
excluded_series_genres: list[str] = []
14+
excluded_movie_genres: list[str] = Field(default_factory=list)
15+
excluded_series_genres: list[str] = Field(default_factory=list)
1616

1717

1818
def get_default_settings() -> UserSettings:

app/core/version.py

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

app/models/profile.py

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,6 @@
22

33

44
class SparseVector(BaseModel):
5-
"""
6-
Represents a sparse vector where keys are feature IDs and values are weights.
7-
For countries, keys can be string codes (hashed or mapped to int if strictly int keys needed,
8-
but let's check if we can use str keys or if we stick to int.
9-
Original SparseVector uses `dict[int, float]`.
10-
TMDB country codes are strings (e.g. "US").
11-
We can either map them to ints or change the model to support str keys.
12-
Let's update the model to support string keys for versatility, or keep int and hash strings.
13-
However, for Pydantic and JSON, string keys are native.
14-
Let's change keys to string/int union or just strings (since ints are valid dict keys too).
15-
Actually, since `genres` IDs are ints, let's allow both or specific types.
16-
For simplicity, let's stick to `dict[str, float]` since JSON keys are strings anyway.
17-
But wait, existing code uses ints for IDs.
18-
Let's make a separate StringSparseVector or just genericize it.
19-
"""
205

216
values: dict[int, float] = Field(default_factory=dict)
227

@@ -67,6 +52,8 @@ class UserTasteProfile(BaseModel):
6752
crew: SparseVector = Field(default_factory=SparseVector)
6853
years: SparseVector = Field(default_factory=SparseVector)
6954
countries: StringSparseVector = Field(default_factory=StringSparseVector)
55+
# Free-text/topic tokens from titles/overviews/keyword names
56+
topics: StringSparseVector = Field(default_factory=StringSparseVector)
7057

7158
def normalize_all(self):
7259
"""Normalize all component vectors."""
@@ -76,6 +63,7 @@ def normalize_all(self):
7663
self.crew.normalize()
7764
self.years.normalize()
7865
self.countries.normalize()
66+
self.topics.normalize()
7967

8068
def get_top_genres(self, limit: int = 3) -> list[tuple[int, float]]:
8169
return self.genres.get_top_features(limit)

app/services/catalog.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from app.services.row_generator import RowGeneratorService
55
from app.services.scoring import ScoringService
66
from app.services.stremio_service import StremioService
7-
from app.services.tmdb_service import TMDBService
7+
from app.services.tmdb_service import get_tmdb_service
88
from app.services.user_profile import UserProfileService
99

1010

@@ -13,11 +13,11 @@ class DynamicCatalogService:
1313
Generates dynamic catalog rows based on user library and preferences.
1414
"""
1515

16-
def __init__(self, stremio_service: StremioService):
16+
def __init__(self, stremio_service: StremioService, language: str = "en-US"):
1717
self.stremio_service = stremio_service
18-
self.tmdb_service = TMDBService()
18+
self.tmdb_service = get_tmdb_service(language=language)
1919
self.scoring_service = ScoringService()
20-
self.user_profile_service = UserProfileService()
20+
self.user_profile_service = UserProfileService(language=language)
2121
self.row_generator = RowGeneratorService(tmdb_service=self.tmdb_service)
2222

2323
@staticmethod

app/services/catalog_updater.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,6 @@ async def refresh_catalogs_for_credentials(token: str, credentials: dict[str, An
3838
logger.exception(f"[{redact_token(token)}] Failed to check if addon is installed: {e}")
3939

4040
try:
41-
library_items = await stremio_service.get_library_items()
42-
dynamic_catalog_service = DynamicCatalogService(stremio_service=stremio_service)
43-
4441
# Ensure user_settings is available
4542
user_settings = get_default_settings()
4643
if credentials.get("settings"):
@@ -49,7 +46,12 @@ async def refresh_catalogs_for_credentials(token: str, credentials: dict[str, An
4946
except Exception as e:
5047
user_settings = get_default_settings()
5148
logger.warning(f"[{redact_token(token)}] Failed to parse user settings from credentials: {e}")
52-
49+
# force fresh library for background refresh
50+
library_items = await stremio_service.get_library_items(use_cache=False)
51+
dynamic_catalog_service = DynamicCatalogService(
52+
stremio_service=stremio_service,
53+
language=(user_settings.language if user_settings else "en-US"),
54+
)
5355
catalogs = await dynamic_catalog_service.get_dynamic_catalogs(
5456
library_items=library_items, user_settings=user_settings
5557
)

0 commit comments

Comments
 (0)