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
75 changes: 41 additions & 34 deletions app/api/endpoints/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,50 @@
router = APIRouter()


async def fetch_languages_list():
"""
Fetch and format languages list from TMDB.
Returns a list of language dictionaries with iso_639_1, language, and country.
"""
tmdb = get_tmdb_service()
tasks = [
tmdb.get_primary_translations(),
tmdb.get_languages(),
tmdb.get_countries(),
]
primary_translations, languages, countries = await asyncio.gather(*tasks)

language_map = {lang["iso_639_1"]: lang["english_name"] for lang in languages}
country_map = {country["iso_3166_1"]: country["english_name"] for country in countries}

result = []
for element in primary_translations:
# element looks like "en-US"
parts = element.split("-")
if len(parts) != 2:
continue

lang_code, country_code = parts
language_name = language_map.get(lang_code)
country_name = country_map.get(country_code)

if language_name and country_name:
result.append(
{
"iso_639_1": element,
"language": language_name,
"country": country_name,
}
)
result.sort(key=lambda x: (x["iso_639_1"] != "en-US", x["language"]))
return result


@router.get("/api/languages")
async def get_languages():
try:
tmdb = get_tmdb_service()
tasks = [
tmdb.get_primary_translations(),
tmdb.get_languages(),
tmdb.get_countries(),
]
primary_translations, languages, countries = await asyncio.gather(*tasks)

language_map = {lang["iso_639_1"]: lang["english_name"] for lang in languages}

country_map = {country["iso_3166_1"]: country["english_name"] for country in countries}

result = []
for element in primary_translations:
# element looks like "en-US"
parts = element.split("-")
if len(parts) != 2:
continue

lang_code, country_code = parts

language_name = language_map.get(lang_code)
country_name = country_map.get(country_code)

if language_name and country_name:
result.append(
{
"iso_639_1": element,
"language": language_name,
"country": country_name,
}
)
return result

languages = await fetch_languages_list()
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")
61 changes: 27 additions & 34 deletions app/core/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import os
from contextlib import asynccontextmanager
from pathlib import Path

Expand All @@ -7,14 +6,20 @@
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader
from loguru import logger

from app.api.endpoints.meta import fetch_languages_list
from app.api.main import api_router
from app.services.token_store import token_store

from .config import settings
from .version import __version__

project_root = Path(__file__).resolve().parent.parent.parent
static_dir = project_root / "app/static"
templates_dir = project_root / "app/templates"


@asynccontextmanager
async def lifespan(app: FastAPI):
Expand Down Expand Up @@ -46,11 +51,8 @@ async def lifespan(app: FastAPI):
allow_headers=["*"],
)


# Simple IP-based rate limiter for repeated probes of missing tokens.
# Tracks recent failure counts per IP to avoid expensive repeated requests.
_ip_failure_cache: TTLCache = TTLCache(maxsize=10000, ttl=600)
_IP_FAILURE_THRESHOLD = 8
IP_FAILURE_THRESHOLD = 8


@app.middleware("http")
Expand All @@ -66,50 +68,41 @@ async def block_missing_token_middleware(request: Request, call_next):
_ip_failure_cache[ip] = _ip_failure_cache.get(ip, 0) + 1
except Exception:
pass
if _ip_failure_cache.get(ip, 0) > _IP_FAILURE_THRESHOLD:
if _ip_failure_cache.get(ip, 0) > IP_FAILURE_THRESHOLD:
return HTMLResponse(content="Too many requests", status_code=429)
return HTMLResponse(content="Invalid token", status_code=401)
except Exception:
pass
return await call_next(request)


# Serve static files
# app/core/app.py -> app/core -> app -> root
project_root = Path(__file__).resolve().parent.parent.parent
static_dir = project_root / "app/static"

if static_dir.exists():
app.mount("/app/static", StaticFiles(directory=str(static_dir)), name="static")

# Initialize Jinja2 templates
jinja_env = Environment(loader=FileSystemLoader(str(templates_dir)))


# Serve index.html at /configure and /{token}/configure
@app.get("/", response_class=HTMLResponse)
@app.get("/configure", response_class=HTMLResponse)
@app.get("/{token}/configure", response_class=HTMLResponse)
async def configure_page(token: str | None = None):
index_path = static_dir / "index.html"
if index_path.exists():
with open(index_path, encoding="utf-8") as file:
html_content = file.read()
dynamic_announcement = os.getenv("ANNOUNCEMENT_HTML")
if dynamic_announcement is None:
dynamic_announcement = settings.ANNOUNCEMENT_HTML
announcement_html = (dynamic_announcement or "").strip()
snippet = ""
if announcement_html:
snippet = f'\n <div class="announcement">{announcement_html}</div>'
html_content = html_content.replace("<!-- ANNOUNCEMENT_HTML -->", snippet, 1)
# Inject version
html_content = html_content.replace("<!-- APP_VERSION -->", __version__, 1)
# Inject host
html_content = html_content.replace("<!-- APP_HOST -->", settings.HOST_NAME, 1)
return HTMLResponse(content=html_content, media_type="text/html")
return HTMLResponse(
content="Watchly API is running. Static files not found.",
media_type="text/plain",
status_code=200,
async def configure_page(request: Request, _token: str | None = None):
languages = []
try:
languages = await fetch_languages_list()
except Exception as e:
logger.warning(f"Failed to fetch languages for template: {e}")
languages = [{"iso_639_1": "en-US", "language": "English", "country": "US"}]

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,
)
return HTMLResponse(content=html_content, media_type="text/html")


app.include_router(api_router)
1 change: 0 additions & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,4 @@ class Settings(BaseSettings):

settings = Settings()

# Get version from version.py (single source of truth)
APP_VERSION = __version__
4 changes: 2 additions & 2 deletions app/core/constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
RECOMMENDATIONS_CATALOG_NAME: str = "Top Picks For You"
DEFAULT_MIN_ITEMS: int = 20
DEFAULT_MAX_ITEMS: int = 32
DEFAULT_MIN_ITEMS: int = 8
DEFAULT_CATALOG_LIMIT = 20

DEFAULT_CONCURRENCY_LIMIT: int = 30

Expand Down
2 changes: 0 additions & 2 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ 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")
min_items: int = Field(default=20, ge=1, le=20)
max_items: int = Field(default=24, ge=1, le=32)


class UserSettings(BaseModel):
Expand Down
57 changes: 12 additions & 45 deletions app/services/recommendation/catalog_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
from fastapi import HTTPException
from loguru import logger

from app.api.endpoints.manifest import get_config_id
from app.core.config import settings
from app.core.constants import DEFAULT_MAX_ITEMS, DEFAULT_MIN_ITEMS
from app.core.constants import DEFAULT_CATALOG_LIMIT, DEFAULT_MIN_ITEMS
from app.core.settings import UserSettings, get_default_settings
from app.models.taste_profile import TasteProfile
from app.services.catalog_updater import catalog_updater
Expand Down Expand Up @@ -85,9 +84,6 @@ async def get_catalog(
)
whitelist = await integration_service.get_genre_whitelist(profile, content_type) if profile else set()

# Get catalog limits
min_items, max_items = self._get_catalog_limits(catalog_id, user_settings)

# Route to appropriate recommendation service
recommendations = await self._get_recommendations(
catalog_id=catalog_id,
Expand All @@ -98,16 +94,16 @@ async def get_catalog(
watched_imdb=watched_imdb,
whitelist=whitelist,
library_items=library_items,
max_items=max_items,
limit=DEFAULT_CATALOG_LIMIT,
)

# Pad if needed
# TODO: This is risky because it can fetch too many unrelated items.
if recommendations and len(recommendations) < PAD_RECOMMENDATIONS_THRESHOLD:
# Pad if needed to meet minimum of 8 items
# # TODO: This is risky because it can fetch too many unrelated items.
if recommendations and len(recommendations) < DEFAULT_MIN_ITEMS:
recommendations = await pad_to_min(
content_type,
recommendations,
PAD_RECOMMENDATIONS_TARGET,
DEFAULT_MIN_ITEMS,
services["tmdb"],
user_settings,
watched_tmdb,
Expand Down Expand Up @@ -195,35 +191,6 @@ def _initialize_services(self, language: str, user_settings: UserSettings) -> di
"all_based": AllBasedService(tmdb_service, user_settings),
}

def _get_catalog_limits(self, catalog_id: str, user_settings: UserSettings) -> tuple[int, int]:
try:
cfg_id = get_config_id({"id": catalog_id})
except Exception:
cfg_id = catalog_id

try:
cfg = next((c for c in user_settings.catalogs if c.id == cfg_id), None)
if cfg and hasattr(cfg, "min_items") and hasattr(cfg, "max_items"):
min_items = int(cfg.min_items or DEFAULT_MIN_ITEMS)
max_items = int(cfg.max_items or DEFAULT_MAX_ITEMS)
else:
min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
except Exception:
min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS

# Enforce caps
try:
min_items = max(1, min(DEFAULT_MIN_ITEMS, int(min_items)))
max_items = max(min_items, min(DEFAULT_MAX_ITEMS, int(max_items)))
except (ValueError, TypeError):
logger.warning(
"Invalid min/max items values. Falling back to defaults. "
f"min_items={min_items}, max_items={max_items}"
)
min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS

return min_items, max_items

async def _get_recommendations(
self,
catalog_id: str,
Expand All @@ -234,7 +201,7 @@ async def _get_recommendations(
watched_imdb: set[str],
whitelist: set[int],
library_items: dict,
max_items: int,
limit: int,
) -> list[dict[str, Any]]:
"""Route to appropriate recommendation service based on catalog ID."""
# Item-based recommendations
Expand All @@ -255,7 +222,7 @@ async def _get_recommendations(
content_type=content_type,
watched_tmdb=watched_tmdb,
watched_imdb=watched_imdb,
limit=max_items,
limit=limit,
whitelist=whitelist,
)
logger.info(f"Found {len(recommendations)} recommendations for item {item_id}")
Expand All @@ -270,7 +237,7 @@ async def _get_recommendations(
profile=profile,
watched_tmdb=watched_tmdb,
watched_imdb=watched_imdb,
limit=max_items,
limit=limit,
whitelist=whitelist,
)
logger.info(f"Found {len(recommendations)} recommendations for theme {catalog_id}")
Expand All @@ -285,7 +252,7 @@ async def _get_recommendations(
content_type=content_type,
watched_tmdb=watched_tmdb,
watched_imdb=watched_imdb,
limit=max_items,
limit=limit,
)
else:
recommendations = []
Expand All @@ -302,7 +269,7 @@ async def _get_recommendations(
library_items=library_items,
watched_tmdb=watched_tmdb,
watched_imdb=watched_imdb,
limit=max_items,
limit=limit,
)
else:
recommendations = []
Expand All @@ -318,7 +285,7 @@ async def _get_recommendations(
watched_tmdb=watched_tmdb,
watched_imdb=watched_imdb,
whitelist=whitelist,
limit=max_items,
limit=limit,
item_type=item_type,
profile=profile,
)
Expand Down
Loading