Skip to content

Commit 7e74aa7

Browse files
Merge branch 'main' of github.com:TimilsinaBimal/Watchly
2 parents ce9b681 + 4ab67ad commit 7e74aa7

27 files changed

+2536
-2209
lines changed

app/api/endpoints/meta.py

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,43 +8,50 @@
88
router = APIRouter()
99

1010

11+
async def fetch_languages_list():
12+
"""
13+
Fetch and format languages list from TMDB.
14+
Returns a list of language dictionaries with iso_639_1, language, and country.
15+
"""
16+
tmdb = get_tmdb_service()
17+
tasks = [
18+
tmdb.get_primary_translations(),
19+
tmdb.get_languages(),
20+
tmdb.get_countries(),
21+
]
22+
primary_translations, languages, countries = await asyncio.gather(*tasks)
23+
24+
language_map = {lang["iso_639_1"]: lang["english_name"] for lang in languages}
25+
country_map = {country["iso_3166_1"]: country["english_name"] for country in countries}
26+
27+
result = []
28+
for element in primary_translations:
29+
# element looks like "en-US"
30+
parts = element.split("-")
31+
if len(parts) != 2:
32+
continue
33+
34+
lang_code, country_code = parts
35+
language_name = language_map.get(lang_code)
36+
country_name = country_map.get(country_code)
37+
38+
if language_name and country_name:
39+
result.append(
40+
{
41+
"iso_639_1": element,
42+
"language": language_name,
43+
"country": country_name,
44+
}
45+
)
46+
result.sort(key=lambda x: (x["iso_639_1"] != "en-US", x["language"]))
47+
return result
48+
49+
1150
@router.get("/api/languages")
1251
async def get_languages():
1352
try:
14-
tmdb = get_tmdb_service()
15-
tasks = [
16-
tmdb.get_primary_translations(),
17-
tmdb.get_languages(),
18-
tmdb.get_countries(),
19-
]
20-
primary_translations, languages, countries = await asyncio.gather(*tasks)
21-
22-
language_map = {lang["iso_639_1"]: lang["english_name"] for lang in languages}
23-
24-
country_map = {country["iso_3166_1"]: country["english_name"] for country in countries}
25-
26-
result = []
27-
for element in primary_translations:
28-
# element looks like "en-US"
29-
parts = element.split("-")
30-
if len(parts) != 2:
31-
continue
32-
33-
lang_code, country_code = parts
34-
35-
language_name = language_map.get(lang_code)
36-
country_name = country_map.get(country_code)
37-
38-
if language_name and country_name:
39-
result.append(
40-
{
41-
"iso_639_1": element,
42-
"language": language_name,
43-
"country": country_name,
44-
}
45-
)
46-
return result
47-
53+
languages = await fetch_languages_list()
54+
return languages
4855
except Exception as e:
4956
logger.error(f"Failed to fetch languages: {e}")
5057
raise HTTPException(status_code=502, detail="Failed to fetch languages from TMDB")

app/core/app.py

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import os
21
from contextlib import asynccontextmanager
32
from pathlib import Path
43

@@ -7,14 +6,20 @@
76
from fastapi.middleware.cors import CORSMiddleware
87
from fastapi.responses import HTMLResponse
98
from fastapi.staticfiles import StaticFiles
9+
from jinja2 import Environment, FileSystemLoader
1010
from loguru import logger
1111

12+
from app.api.endpoints.meta import fetch_languages_list
1213
from app.api.main import api_router
1314
from app.services.token_store import token_store
1415

1516
from .config import settings
1617
from .version import __version__
1718

19+
project_root = Path(__file__).resolve().parent.parent.parent
20+
static_dir = project_root / "app/static"
21+
templates_dir = project_root / "app/templates"
22+
1823

1924
@asynccontextmanager
2025
async def lifespan(app: FastAPI):
@@ -46,11 +51,8 @@ async def lifespan(app: FastAPI):
4651
allow_headers=["*"],
4752
)
4853

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

5557

5658
@app.middleware("http")
@@ -66,50 +68,41 @@ async def block_missing_token_middleware(request: Request, call_next):
6668
_ip_failure_cache[ip] = _ip_failure_cache.get(ip, 0) + 1
6769
except Exception:
6870
pass
69-
if _ip_failure_cache.get(ip, 0) > _IP_FAILURE_THRESHOLD:
71+
if _ip_failure_cache.get(ip, 0) > IP_FAILURE_THRESHOLD:
7072
return HTMLResponse(content="Too many requests", status_code=429)
7173
return HTMLResponse(content="Invalid token", status_code=401)
7274
except Exception:
7375
pass
7476
return await call_next(request)
7577

7678

77-
# Serve static files
78-
# app/core/app.py -> app/core -> app -> root
79-
project_root = Path(__file__).resolve().parent.parent.parent
80-
static_dir = project_root / "app/static"
81-
8279
if static_dir.exists():
8380
app.mount("/app/static", StaticFiles(directory=str(static_dir)), name="static")
8481

82+
# Initialize Jinja2 templates
83+
jinja_env = Environment(loader=FileSystemLoader(str(templates_dir)))
84+
8585

86-
# Serve index.html at /configure and /{token}/configure
8786
@app.get("/", response_class=HTMLResponse)
8887
@app.get("/configure", response_class=HTMLResponse)
8988
@app.get("/{token}/configure", response_class=HTMLResponse)
90-
async def configure_page(token: str | None = None):
91-
index_path = static_dir / "index.html"
92-
if index_path.exists():
93-
with open(index_path, encoding="utf-8") as file:
94-
html_content = file.read()
95-
dynamic_announcement = os.getenv("ANNOUNCEMENT_HTML")
96-
if dynamic_announcement is None:
97-
dynamic_announcement = settings.ANNOUNCEMENT_HTML
98-
announcement_html = (dynamic_announcement or "").strip()
99-
snippet = ""
100-
if announcement_html:
101-
snippet = f'\n <div class="announcement">{announcement_html}</div>'
102-
html_content = html_content.replace("<!-- ANNOUNCEMENT_HTML -->", snippet, 1)
103-
# Inject version
104-
html_content = html_content.replace("<!-- APP_VERSION -->", __version__, 1)
105-
# Inject host
106-
html_content = html_content.replace("<!-- APP_HOST -->", settings.HOST_NAME, 1)
107-
return HTMLResponse(content=html_content, media_type="text/html")
108-
return HTMLResponse(
109-
content="Watchly API is running. Static files not found.",
110-
media_type="text/plain",
111-
status_code=200,
89+
async def configure_page(request: Request, _token: str | None = None):
90+
languages = []
91+
try:
92+
languages = await fetch_languages_list()
93+
except Exception as e:
94+
logger.warning(f"Failed to fetch languages for template: {e}")
95+
languages = [{"iso_639_1": "en-US", "language": "English", "country": "US"}]
96+
97+
template = jinja_env.get_template("index.html")
98+
html_content = template.render(
99+
request=request,
100+
app_version=__version__,
101+
app_host=settings.HOST_NAME,
102+
announcement_html=settings.ANNOUNCEMENT_HTML or "",
103+
languages=languages,
112104
)
105+
return HTMLResponse(content=html_content, media_type="text/html")
113106

114107

115108
app.include_router(api_router)

app/core/config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,4 @@ class Settings(BaseSettings):
4747

4848
settings = Settings()
4949

50-
# Get version from version.py (single source of truth)
5150
APP_VERSION = __version__

app/core/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
RECOMMENDATIONS_CATALOG_NAME: str = "Top Picks For You"
2-
DEFAULT_MIN_ITEMS: int = 20
3-
DEFAULT_MAX_ITEMS: int = 32
2+
DEFAULT_MIN_ITEMS: int = 8
3+
DEFAULT_CATALOG_LIMIT = 20
44

55
DEFAULT_CONCURRENCY_LIMIT: int = 30
66

app/core/settings.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ class CatalogConfig(BaseModel):
77
enabled: bool = True
88
enabled_movie: bool = Field(default=True, description="Enable movie catalog for this configuration")
99
enabled_series: bool = Field(default=True, description="Enable series catalog for this configuration")
10-
min_items: int = Field(default=20, ge=1, le=20)
11-
max_items: int = Field(default=24, ge=1, le=32)
1210

1311

1412
class UserSettings(BaseModel):

app/services/recommendation/catalog_service.py

Lines changed: 12 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44
from fastapi import HTTPException
55
from loguru import logger
66

7-
from app.api.endpoints.manifest import get_config_id
87
from app.core.config import settings
9-
from app.core.constants import DEFAULT_MAX_ITEMS, DEFAULT_MIN_ITEMS
8+
from app.core.constants import DEFAULT_CATALOG_LIMIT, DEFAULT_MIN_ITEMS
109
from app.core.settings import UserSettings, get_default_settings
1110
from app.models.taste_profile import TasteProfile
1211
from app.services.catalog_updater import catalog_updater
@@ -85,9 +84,6 @@ async def get_catalog(
8584
)
8685
whitelist = await integration_service.get_genre_whitelist(profile, content_type) if profile else set()
8786

88-
# Get catalog limits
89-
min_items, max_items = self._get_catalog_limits(catalog_id, user_settings)
90-
9187
# Route to appropriate recommendation service
9288
recommendations = await self._get_recommendations(
9389
catalog_id=catalog_id,
@@ -98,16 +94,16 @@ async def get_catalog(
9894
watched_imdb=watched_imdb,
9995
whitelist=whitelist,
10096
library_items=library_items,
101-
max_items=max_items,
97+
limit=DEFAULT_CATALOG_LIMIT,
10298
)
10399

104-
# Pad if needed
105-
# TODO: This is risky because it can fetch too many unrelated items.
106-
if recommendations and len(recommendations) < PAD_RECOMMENDATIONS_THRESHOLD:
100+
# Pad if needed to meet minimum of 8 items
101+
# # TODO: This is risky because it can fetch too many unrelated items.
102+
if recommendations and len(recommendations) < DEFAULT_MIN_ITEMS:
107103
recommendations = await pad_to_min(
108104
content_type,
109105
recommendations,
110-
PAD_RECOMMENDATIONS_TARGET,
106+
DEFAULT_MIN_ITEMS,
111107
services["tmdb"],
112108
user_settings,
113109
watched_tmdb,
@@ -195,35 +191,6 @@ def _initialize_services(self, language: str, user_settings: UserSettings) -> di
195191
"all_based": AllBasedService(tmdb_service, user_settings),
196192
}
197193

198-
def _get_catalog_limits(self, catalog_id: str, user_settings: UserSettings) -> tuple[int, int]:
199-
try:
200-
cfg_id = get_config_id({"id": catalog_id})
201-
except Exception:
202-
cfg_id = catalog_id
203-
204-
try:
205-
cfg = next((c for c in user_settings.catalogs if c.id == cfg_id), None)
206-
if cfg and hasattr(cfg, "min_items") and hasattr(cfg, "max_items"):
207-
min_items = int(cfg.min_items or DEFAULT_MIN_ITEMS)
208-
max_items = int(cfg.max_items or DEFAULT_MAX_ITEMS)
209-
else:
210-
min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
211-
except Exception:
212-
min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
213-
214-
# Enforce caps
215-
try:
216-
min_items = max(1, min(DEFAULT_MIN_ITEMS, int(min_items)))
217-
max_items = max(min_items, min(DEFAULT_MAX_ITEMS, int(max_items)))
218-
except (ValueError, TypeError):
219-
logger.warning(
220-
"Invalid min/max items values. Falling back to defaults. "
221-
f"min_items={min_items}, max_items={max_items}"
222-
)
223-
min_items, max_items = DEFAULT_MIN_ITEMS, DEFAULT_MAX_ITEMS
224-
225-
return min_items, max_items
226-
227194
async def _get_recommendations(
228195
self,
229196
catalog_id: str,
@@ -234,7 +201,7 @@ async def _get_recommendations(
234201
watched_imdb: set[str],
235202
whitelist: set[int],
236203
library_items: dict,
237-
max_items: int,
204+
limit: int,
238205
) -> list[dict[str, Any]]:
239206
"""Route to appropriate recommendation service based on catalog ID."""
240207
# Item-based recommendations
@@ -255,7 +222,7 @@ async def _get_recommendations(
255222
content_type=content_type,
256223
watched_tmdb=watched_tmdb,
257224
watched_imdb=watched_imdb,
258-
limit=max_items,
225+
limit=limit,
259226
whitelist=whitelist,
260227
)
261228
logger.info(f"Found {len(recommendations)} recommendations for item {item_id}")
@@ -270,7 +237,7 @@ async def _get_recommendations(
270237
profile=profile,
271238
watched_tmdb=watched_tmdb,
272239
watched_imdb=watched_imdb,
273-
limit=max_items,
240+
limit=limit,
274241
whitelist=whitelist,
275242
)
276243
logger.info(f"Found {len(recommendations)} recommendations for theme {catalog_id}")
@@ -285,7 +252,7 @@ async def _get_recommendations(
285252
content_type=content_type,
286253
watched_tmdb=watched_tmdb,
287254
watched_imdb=watched_imdb,
288-
limit=max_items,
255+
limit=limit,
289256
)
290257
else:
291258
recommendations = []
@@ -302,7 +269,7 @@ async def _get_recommendations(
302269
library_items=library_items,
303270
watched_tmdb=watched_tmdb,
304271
watched_imdb=watched_imdb,
305-
limit=max_items,
272+
limit=limit,
306273
)
307274
else:
308275
recommendations = []
@@ -318,7 +285,7 @@ async def _get_recommendations(
318285
watched_tmdb=watched_tmdb,
319286
watched_imdb=watched_imdb,
320287
whitelist=whitelist,
321-
limit=max_items,
288+
limit=limit,
322289
item_type=item_type,
323290
profile=profile,
324291
)

0 commit comments

Comments
 (0)