Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c5fbb29
chore: bump version to v1.8.0
TimilsinaBimal Feb 3, 2026
b3850c6
feat: add option to group sort globally
TimilsinaBimal Feb 4, 2026
30e45be
chore: bump version to v1.8.1-rc.1
TimilsinaBimal Feb 4, 2026
a5b2ec8
feat: add error message on addon description for failed addon update
TimilsinaBimal Feb 6, 2026
3ed915f
fix: remove . after patch number and include - for rc releases
TimilsinaBimal Feb 9, 2026
94cf964
feat: add option to fetch recommendatins from simkl
TimilsinaBimal Feb 10, 2026
13699bd
feat: add total number of users in ui
TimilsinaBimal Feb 10, 2026
bb4724a
fix: simkl trending items not working due to get on list
TimilsinaBimal Feb 10, 2026
43e4336
feat: fetch recommendations from simkl for all loved/liked items
TimilsinaBimal Feb 14, 2026
659f578
feat: generate interest summary and theme catalogs using LLM
TimilsinaBimal Feb 14, 2026
2f9628f
fix: only retry retriable errors
TimilsinaBimal Feb 14, 2026
e3865c8
feat: add field to add tmdb api key required
TimilsinaBimal Feb 14, 2026
a0d5f8c
refactor: merge validation endpoints into one
TimilsinaBimal Feb 14, 2026
d02f17d
refactor: add pydantic model validation for stats endpoint
TimilsinaBimal Feb 14, 2026
2256b3e
Merge branch 'main' of github.com:TimilsinaBimal/Watchly into dev
TimilsinaBimal Feb 14, 2026
3652bbe
fix: remove cache control when there are no recommendations
TimilsinaBimal Feb 19, 2026
3b6f726
fix: improve user settings filtering for simkl candidates
TimilsinaBimal Feb 19, 2026
2354c4d
fix: improve user settings filtering for simkl candidates
TimilsinaBimal Feb 19, 2026
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
13 changes: 6 additions & 7 deletions app/api/endpoints/catalogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,7 @@

@router.get("/{token}/catalog/{type}/{id}.json")
@router.get("/{token}/catalog/{type}/{id}/{extra}.json")
async def get_catalog(response: Response, type: str, id: str, token: str, extra: str | None = None):
"""
Get catalog recommendations.

This endpoint delegates all logic to CatalogService facade.
"""
async def get_catalog(response: Response, type: str, id: str, token: str, extra: str | None = None) -> dict:
if type not in ("movie", "series"):
raise HTTPException(status_code=400, detail="Invalid content type. Must be 'movie' or 'series'.")

Expand All @@ -29,10 +24,14 @@ async def get_catalog(response: Response, type: str, id: str, token: str, extra:
for key, value in headers.items():
response.headers[key] = value

# if recommendations are none or empty, then set cache header to no-cache
if recommendations and not recommendations.get("meta"):
response.headers["Cache-Control"] = "no-cache"

return recommendations

except HTTPException:
raise
except Exception as e:
logger.exception(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=f"Something went wrong. Please try again. Error: {e}")
45 changes: 0 additions & 45 deletions app/api/endpoints/poster_rating.py

This file was deleted.

13 changes: 5 additions & 8 deletions app/api/endpoints/stats.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
from fastapi import APIRouter
from loguru import logger

from app.api.models.stats import StatsResponse
from app.services.token_store import token_store

router = APIRouter()
router = APIRouter(tags=["Stats"])


@router.get("/stats")
async def get_stats() -> dict:
"""Return lightweight public stats for the homepage.

Total users is cached for 12 hours inside TokenStore to avoid heavy scans.
"""
async def get_stats() -> StatsResponse:
try:
total = await token_store.count_users()
except Exception as exc:
logger.warning(f"Failed to get total users: {exc}")
logger.error(f"Failed to get total users: {exc}")
total = 0
return {"total_users": total}
return StatsResponse(total_users=total)
21 changes: 20 additions & 1 deletion app/api/endpoints/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ class TokenRequest(BaseModel):
)
year_min: int = Field(default=2010, description="Minimum release year for TMDB API")
year_max: int = Field(default=2025, description="Maximum release year for TMDB API")
sorting_order: Literal["default", "movies_first", "series_first"] = Field(
default="default", description="Order of movies and series catalogs"
)
simkl_api_key: str | None = Field(default=None, description="Simkl API Key for the user")
gemini_api_key: str | None = Field(default=None, description="Gemini API Key for AI features")
tmdb_api_key: str | None = Field(
default=None, description="TMDB API Key (required for new clients if server has none)"
)


class TokenResponse(BaseModel):
Expand Down Expand Up @@ -94,6 +102,10 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
year_min=payload.year_min,
year_max=payload.year_max,
popularity=payload.popularity,
sorting_order=payload.sorting_order,
simkl_api_key=payload.simkl_api_key,
gemini_api_key=payload.gemini_api_key,
tmdb_api_key=payload.tmdb_api_key,
)

# 4. Prepare payload to store
Expand Down Expand Up @@ -186,7 +198,14 @@ async def check_stremio_identity(payload: TokenRequest):

response = {"user_id": user_id, "email": email, "exists": exists}
if exists and user_data:
response["settings"] = user_data.get("settings")
# Reconstruct UserSettings to ensure defaults (like sorting_order) are included for old accounts
raw_settings = user_data.get("settings", {})
try:
user_settings = UserSettings(**raw_settings)
response["settings"] = user_settings.model_dump()
except Exception as e:
logger.warning(f"Failed to normalize settings for user {user_id}: {e}")
response["settings"] = raw_settings
return response


Expand Down
71 changes: 71 additions & 0 deletions app/api/endpoints/validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from fastapi import APIRouter, HTTPException
from google import genai
from loguru import logger

from app.api.models.validation import BaseValidationInput, BaseValidationResponse, PosterRatingValidationInput
from app.services.poster_ratings.factory import PosterProvider, poster_ratings_factory
from app.services.simkl import simkl_service
from app.services.tmdb.client import TMDBClient

router = APIRouter(tags=["Validation"])


@router.post("/gemini/validation")
async def validate_gemini_api_key(data: BaseValidationInput) -> BaseValidationResponse:
try:
client = genai.Client(api_key=data.api_key.strip())
await client.aio.models.list()
return BaseValidationResponse(valid=True, message="Gemini API key is valid")
except Exception as e:
logger.debug(f"Gemini API key validation failed: {e}")
return BaseValidationResponse(valid=False, message="Invalid Gemini API key")


@router.post("/tmdb/validation")
async def validate_tmdb_api_key(data: BaseValidationInput) -> BaseValidationResponse:
try:
client = TMDBClient(api_key=data.api_key.strip(), language="en-US")
await client.get("/configuration")
await client.close()
return BaseValidationResponse(valid=True, message="TMDB API key is valid")
except Exception as e:
logger.debug(f"TMDB API key validation failed: {e}")
return BaseValidationResponse(valid=False, message="Invalid TMDB API key")


@router.post("/poster-rating/validate")
async def validate_poster_rating_api_key(payload: PosterRatingValidationInput) -> BaseValidationResponse:
if not payload.api_key or not payload.api_key.strip():
return BaseValidationResponse(valid=False, message="API key cannot be empty")

try:
provider_enum = PosterProvider(payload.provider)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid provider: {payload.provider}")

try:
if provider_enum == PosterProvider.RPDB:
is_valid = await poster_ratings_factory.rpdb_service.validate_api_key(payload.api_key.strip())
elif provider_enum == PosterProvider.TOP_POSTERS:
is_valid = await poster_ratings_factory.top_posters_service.validate_api_key(payload.api_key.strip())
else:
raise HTTPException(status_code=400, detail=f"Unsupported provider: {payload.provider}")

if is_valid:
return BaseValidationResponse(valid=True, message="API key is valid")
return BaseValidationResponse(valid=False, message="Invalid API key")
except Exception as e:
logger.error(f"Validation failed: {str(e)}")
raise HTTPException(status_code=500, detail="Validation failed due to an internal error.")


@router.post("/simkl/validation")
async def validate_simkl_api_key(data: BaseValidationInput) -> BaseValidationResponse:
try:
response = await simkl_service.get_trending(data.api_key)
if response:
return BaseValidationResponse(valid=True, message="Valid API Key")
return BaseValidationResponse(valid=False, message="Invalid API Key")
except Exception as e:
logger.error(f"Validation failed: {str(e)}")
raise HTTPException(status_code=500, detail="Validation failed due to an internal error.")
5 changes: 5 additions & 0 deletions app/api/models/stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pydantic import BaseModel


class StatsResponse(BaseModel):
total_users: int
14 changes: 14 additions & 0 deletions app/api/models/validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from pydantic import BaseModel, Field


class BaseValidationInput(BaseModel):
api_key: str = Field(description="API key to validate")


class BaseValidationResponse(BaseModel):
valid: bool
message: str


class PosterRatingValidationInput(BaseValidationInput):
provider: str = Field(description="Provider name: 'rpdb' or 'top_posters'")
4 changes: 2 additions & 2 deletions app/api/main.py → app/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
from .endpoints.health import router as health_router
from .endpoints.manifest import router as manifest_router
from .endpoints.meta import router as meta_router
from .endpoints.poster_rating import router as poster_rating_router
from .endpoints.stats import router as stats_router
from .endpoints.tokens import router as tokens_router
from .endpoints.validation import router as validation_router

api_router = APIRouter()

Expand All @@ -24,4 +24,4 @@ async def root():
api_router.include_router(meta_router)
api_router.include_router(announcement_router)
api_router.include_router(stats_router)
api_router.include_router(poster_rating_router)
api_router.include_router(validation_router)
10 changes: 9 additions & 1 deletion app/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from loguru import logger

from app.api.endpoints.meta import fetch_languages_list
from app.api.main import api_router
from app.api.router import api_router
from app.core.settings import get_default_catalogs_for_frontend
from app.services.redis_service import redis_service
from app.services.tmdb.genre import movie_genres, series_genres
Expand Down Expand Up @@ -106,6 +106,13 @@ async def configure_page(request: Request, _token: str | None = None):
logger.warning(f"Failed to fetch languages for template: {e}")
languages = [{"iso_639_1": "en-US", "language": "English", "country": "US"}]

# Get total users count
total_users = 0
try:
total_users = await token_store.count_users()
except Exception as e:
logger.warning(f"Failed to get total users for template: {e}")

# Format default catalogs for frontend
default_catalogs = get_default_catalogs_for_frontend()

Expand All @@ -117,6 +124,7 @@ async def configure_page(request: Request, _token: str | None = None):
html_content = template.render(
request=request,
app_version=__version__,
total_users=total_users,
app_host=settings.HOST_NAME,
announcement_html=settings.ANNOUNCEMENT_HTML or "",
languages=languages,
Expand Down
23 changes: 16 additions & 7 deletions app/core/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,28 +37,37 @@ async def _request(self, method: str, url: str, max_tries: int | None = None, **
"""Internal request handler with retry logic."""
client = await self.get_client()
tries = max_tries or self.max_retries
last_exception = None

for attempt in range(1, tries + 1):
try:
response = await client.request(method, url, **kwargs)
response.raise_for_status()
return response
except (httpx.HTTPStatusError, httpx.RequestError) as e:
last_exception = e
if attempt < tries:

# Check if the error is retryable
is_retryable = True
if isinstance(e, httpx.HTTPStatusError):
# Only retry on 429 (Rate Limit) and 5xx (Server Errors)
# 404, 400, 401, etc. are not retryable
is_retryable = e.response.status_code in (429, 500, 502, 503, 504)

if is_retryable and attempt < tries:
wait_time = 0.5 * (2 ** (attempt - 1)) # Exponential backoff
logger.warning(
f"Request failed ({method} {url}): {str(e)}. "
f"Retrying in {wait_time}s... (Attempt {attempt}/{tries})"
)
await asyncio.sleep(wait_time)
else:
logger.error(f"Request failed after {tries} attempts: {str(e)}")
# If not retryable or no more attempts left, log and raise
if not is_retryable:
logger.error(f"Non-retryable request failure ({method} {url}): {str(e)}")
else:
logger.error(f"Request failed after {tries} attempts ({method} {url}): {str(e)}")
raise e

if last_exception:
raise last_exception
raise httpx.RequestError("Request failed for unknown reasons")
raise httpx.RequestError(f"Request failed for {method} {url} with 0 attempts configured")

async def get(self, url: str, params: dict[str, Any] | None = None, **kwargs) -> dict[str, Any]:
"""Perform a GET request and return the JSON response."""
Expand Down
15 changes: 15 additions & 0 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ class UserSettings(BaseModel):
popularity: Literal["mainstream", "balanced", "gems", "all"] = Field(
default="balanced", description="Popularity preference"
)
sorting_order: Literal["default", "movies_first", "series_first"] = Field(
default="default", description="Order of movies and series catalogs"
)
simkl_api_key: str | None = Field(default=None, description="Simkl API Key for the user")
gemini_api_key: str | None = Field(default=None, description="Gemini API Key for AI-powered features")
tmdb_api_key: str | None = Field(default=None, description="TMDB API Key (used if set; else server config)")


# Catalog descriptions for frontend
Expand Down Expand Up @@ -155,6 +161,15 @@ def get_default_catalogs_for_frontend() -> list[dict]:
return catalogs


def resolve_tmdb_api_key(user_settings: UserSettings | None) -> str | None:
"""Use TMDB API key from user settings (Redis) if set, else from server config."""
from app.core.config import settings

if user_settings and user_settings.tmdb_api_key:
return user_settings.tmdb_api_key
return settings.TMDB_API_KEY


class Credentials(BaseModel):
authKey: str
email: str
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.8.0"
__version__ = "1.9.0"
1 change: 1 addition & 0 deletions app/models/taste_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class TasteProfile(BaseModel):
default_factory=set,
description="Set of processed item IDs to prevent double counting",
)
interest_summary: str | None = Field(default=None, description="LLM-generated description of user interests")

class Config:
"""Pydantic configuration."""
Expand Down
Loading