Skip to content
Merged
14 changes: 11 additions & 3 deletions app/api/endpoints/catalogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from app.services.token_store import token_store

MAX_RESULTS = 50
SOURCE_ITEMS_LIMIT = 15
SOURCE_ITEMS_LIMIT = 10

router = APIRouter()

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

# Create services with credentials
stremio_service = StremioService(auth_key=credentials.get("authKey"))
# Fetch library once per request and reuse across recommendation paths
library_items = await stremio_service.get_library_items()
recommendation_service = RecommendationService(
stremio_service=stremio_service, language=language, user_settings=user_settings
stremio_service=stremio_service,
language=language,
user_settings=user_settings,
token=token,
library_data=library_items,
)

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

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

except HTTPException:
Expand Down
4 changes: 2 additions & 2 deletions app/api/endpoints/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ async def fetch_catalogs(token: str):

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

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


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

if not token:
raise HTTPException(status_code=401, detail="Missing token. Please reconfigure the addon.")
Expand Down
15 changes: 11 additions & 4 deletions app/api/endpoints/meta.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
from async_lru import alru_cache
from fastapi import APIRouter, HTTPException
from loguru import logger

from app.services.tmdb_service import TMDBService
from app.services.tmdb_service import get_tmdb_service

router = APIRouter()


@alru_cache(maxsize=1, ttl=24 * 60 * 60)
async def _cached_languages():
tmdb = get_tmdb_service()
return await tmdb._make_request("/configuration/languages")


@router.get("/api/languages")
async def get_languages():
"""
Proxy endpoint to fetch languages from TMDB.
"""
tmdb_service = TMDBService()
try:
languages = await tmdb_service._make_request("/configuration/languages")
languages = await _cached_languages()
if not languages:
return []
return languages
except Exception as e:
logger.error(f"Failed to fetch languages: {e}")
raise HTTPException(status_code=502, detail="Failed to fetch languages from TMDB")
finally:
await tmdb_service.close()
# shared client: no explicit close
pass
20 changes: 19 additions & 1 deletion app/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from loguru import logger

from app.api.main import api_router
from app.services.catalog_updater import BackgroundCatalogUpdater
from app.services.token_store import token_store
from app.startup.migration import migrate_tokens

from .config import settings
Expand Down Expand Up @@ -82,6 +83,23 @@ def _on_done(t: asyncio.Task):
allow_headers=["*"],
)


# Middleware to track per-request Redis calls and attach as response header for diagnostics
@app.middleware("http")
async def redis_calls_middleware(request: Request, call_next):
try:
token_store.reset_call_counter()
except Exception:
pass
response = await call_next(request)
try:
count = token_store.get_call_count()
response.headers["X-Redis-Calls"] = str(count)
except Exception:
pass
return response


# Serve static files
# Static directory is at project root (3 levels up from app/core/app.py)
# app/core/app.py -> app/core -> app -> root
Expand Down
6 changes: 3 additions & 3 deletions app/core/settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field


class CatalogConfig(BaseModel):
Expand All @@ -11,8 +11,8 @@ class UserSettings(BaseModel):
catalogs: list[CatalogConfig]
language: str = "en-US"
rpdb_key: str | None = None
excluded_movie_genres: list[str] = []
excluded_series_genres: list[str] = []
excluded_movie_genres: list[str] = Field(default_factory=list)
excluded_series_genres: list[str] = Field(default_factory=list)


def get_default_settings() -> UserSettings:
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.0.1"
__version__ = "1.1.0"
18 changes: 3 additions & 15 deletions app/models/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,6 @@


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

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

Expand Down Expand Up @@ -67,6 +52,8 @@ class UserTasteProfile(BaseModel):
crew: SparseVector = Field(default_factory=SparseVector)
years: SparseVector = Field(default_factory=SparseVector)
countries: StringSparseVector = Field(default_factory=StringSparseVector)
# Free-text/topic tokens from titles/overviews/keyword names
topics: StringSparseVector = Field(default_factory=StringSparseVector)

def normalize_all(self):
"""Normalize all component vectors."""
Expand All @@ -76,6 +63,7 @@ def normalize_all(self):
self.crew.normalize()
self.years.normalize()
self.countries.normalize()
self.topics.normalize()

def get_top_genres(self, limit: int = 3) -> list[tuple[int, float]]:
return self.genres.get_top_features(limit)
Expand Down
8 changes: 4 additions & 4 deletions app/services/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from app.services.row_generator import RowGeneratorService
from app.services.scoring import ScoringService
from app.services.stremio_service import StremioService
from app.services.tmdb_service import TMDBService
from app.services.tmdb_service import get_tmdb_service
from app.services.user_profile import UserProfileService


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

def __init__(self, stremio_service: StremioService):
def __init__(self, stremio_service: StremioService, language: str = "en-US"):
self.stremio_service = stremio_service
self.tmdb_service = TMDBService()
self.tmdb_service = get_tmdb_service(language=language)
self.scoring_service = ScoringService()
self.user_profile_service = UserProfileService()
self.user_profile_service = UserProfileService(language=language)
self.row_generator = RowGeneratorService(tmdb_service=self.tmdb_service)

@staticmethod
Expand Down
10 changes: 6 additions & 4 deletions app/services/catalog_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,6 @@ async def refresh_catalogs_for_credentials(token: str, credentials: dict[str, An
logger.exception(f"[{redact_token(token)}] Failed to check if addon is installed: {e}")

try:
library_items = await stremio_service.get_library_items()
dynamic_catalog_service = DynamicCatalogService(stremio_service=stremio_service)

# Ensure user_settings is available
user_settings = get_default_settings()
if credentials.get("settings"):
Expand All @@ -49,7 +46,12 @@ async def refresh_catalogs_for_credentials(token: str, credentials: dict[str, An
except Exception as e:
user_settings = get_default_settings()
logger.warning(f"[{redact_token(token)}] Failed to parse user settings from credentials: {e}")

# force fresh library for background refresh
library_items = await stremio_service.get_library_items(use_cache=False)
dynamic_catalog_service = DynamicCatalogService(
stremio_service=stremio_service,
language=(user_settings.language if user_settings else "en-US"),
)
catalogs = await dynamic_catalog_service.get_dynamic_catalogs(
library_items=library_items, user_settings=user_settings
)
Expand Down
Loading