Skip to content

Commit f13a7de

Browse files
chore(release): bump version to v1.9.0 (#108)
2 parents 1d6e824 + 2354c4d commit f13a7de

38 files changed

+1698
-181
lines changed

app/api/endpoints/catalogs.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,7 @@
99

1010
@router.get("/{token}/catalog/{type}/{id}.json")
1111
@router.get("/{token}/catalog/{type}/{id}/{extra}.json")
12-
async def get_catalog(response: Response, type: str, id: str, token: str, extra: str | None = None):
13-
"""
14-
Get catalog recommendations.
15-
16-
This endpoint delegates all logic to CatalogService facade.
17-
"""
12+
async def get_catalog(response: Response, type: str, id: str, token: str, extra: str | None = None) -> dict:
1813
if type not in ("movie", "series"):
1914
raise HTTPException(status_code=400, detail="Invalid content type. Must be 'movie' or 'series'.")
2015

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

27+
# if recommendations are none or empty, then set cache header to no-cache
28+
if recommendations and not recommendations.get("meta"):
29+
response.headers["Cache-Control"] = "no-cache"
30+
3231
return recommendations
3332

3433
except HTTPException:
3534
raise
3635
except Exception as e:
3736
logger.exception(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}")
38-
raise HTTPException(status_code=500, detail=str(e))
37+
raise HTTPException(status_code=500, detail=f"Something went wrong. Please try again. Error: {e}")

app/api/endpoints/poster_rating.py

Lines changed: 0 additions & 45 deletions
This file was deleted.

app/api/endpoints/stats.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
11
from fastapi import APIRouter
22
from loguru import logger
33

4+
from app.api.models.stats import StatsResponse
45
from app.services.token_store import token_store
56

6-
router = APIRouter()
7+
router = APIRouter(tags=["Stats"])
78

89

910
@router.get("/stats")
10-
async def get_stats() -> dict:
11-
"""Return lightweight public stats for the homepage.
12-
13-
Total users is cached for 12 hours inside TokenStore to avoid heavy scans.
14-
"""
11+
async def get_stats() -> StatsResponse:
1512
try:
1613
total = await token_store.count_users()
1714
except Exception as exc:
18-
logger.warning(f"Failed to get total users: {exc}")
15+
logger.error(f"Failed to get total users: {exc}")
1916
total = 0
20-
return {"total_users": total}
17+
return StatsResponse(total_users=total)

app/api/endpoints/tokens.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ class TokenRequest(BaseModel):
2929
)
3030
year_min: int = Field(default=2010, description="Minimum release year for TMDB API")
3131
year_max: int = Field(default=2025, description="Maximum release year for TMDB API")
32+
sorting_order: Literal["default", "movies_first", "series_first"] = Field(
33+
default="default", description="Order of movies and series catalogs"
34+
)
35+
simkl_api_key: str | None = Field(default=None, description="Simkl API Key for the user")
36+
gemini_api_key: str | None = Field(default=None, description="Gemini API Key for AI features")
37+
tmdb_api_key: str | None = Field(
38+
default=None, description="TMDB API Key (required for new clients if server has none)"
39+
)
3240

3341

3442
class TokenResponse(BaseModel):
@@ -94,6 +102,10 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse
94102
year_min=payload.year_min,
95103
year_max=payload.year_max,
96104
popularity=payload.popularity,
105+
sorting_order=payload.sorting_order,
106+
simkl_api_key=payload.simkl_api_key,
107+
gemini_api_key=payload.gemini_api_key,
108+
tmdb_api_key=payload.tmdb_api_key,
97109
)
98110

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

187199
response = {"user_id": user_id, "email": email, "exists": exists}
188200
if exists and user_data:
189-
response["settings"] = user_data.get("settings")
201+
# Reconstruct UserSettings to ensure defaults (like sorting_order) are included for old accounts
202+
raw_settings = user_data.get("settings", {})
203+
try:
204+
user_settings = UserSettings(**raw_settings)
205+
response["settings"] = user_settings.model_dump()
206+
except Exception as e:
207+
logger.warning(f"Failed to normalize settings for user {user_id}: {e}")
208+
response["settings"] = raw_settings
190209
return response
191210

192211

app/api/endpoints/validation.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from fastapi import APIRouter, HTTPException
2+
from google import genai
3+
from loguru import logger
4+
5+
from app.api.models.validation import BaseValidationInput, BaseValidationResponse, PosterRatingValidationInput
6+
from app.services.poster_ratings.factory import PosterProvider, poster_ratings_factory
7+
from app.services.simkl import simkl_service
8+
from app.services.tmdb.client import TMDBClient
9+
10+
router = APIRouter(tags=["Validation"])
11+
12+
13+
@router.post("/gemini/validation")
14+
async def validate_gemini_api_key(data: BaseValidationInput) -> BaseValidationResponse:
15+
try:
16+
client = genai.Client(api_key=data.api_key.strip())
17+
await client.aio.models.list()
18+
return BaseValidationResponse(valid=True, message="Gemini API key is valid")
19+
except Exception as e:
20+
logger.debug(f"Gemini API key validation failed: {e}")
21+
return BaseValidationResponse(valid=False, message="Invalid Gemini API key")
22+
23+
24+
@router.post("/tmdb/validation")
25+
async def validate_tmdb_api_key(data: BaseValidationInput) -> BaseValidationResponse:
26+
try:
27+
client = TMDBClient(api_key=data.api_key.strip(), language="en-US")
28+
await client.get("/configuration")
29+
await client.close()
30+
return BaseValidationResponse(valid=True, message="TMDB API key is valid")
31+
except Exception as e:
32+
logger.debug(f"TMDB API key validation failed: {e}")
33+
return BaseValidationResponse(valid=False, message="Invalid TMDB API key")
34+
35+
36+
@router.post("/poster-rating/validate")
37+
async def validate_poster_rating_api_key(payload: PosterRatingValidationInput) -> BaseValidationResponse:
38+
if not payload.api_key or not payload.api_key.strip():
39+
return BaseValidationResponse(valid=False, message="API key cannot be empty")
40+
41+
try:
42+
provider_enum = PosterProvider(payload.provider)
43+
except ValueError:
44+
raise HTTPException(status_code=400, detail=f"Invalid provider: {payload.provider}")
45+
46+
try:
47+
if provider_enum == PosterProvider.RPDB:
48+
is_valid = await poster_ratings_factory.rpdb_service.validate_api_key(payload.api_key.strip())
49+
elif provider_enum == PosterProvider.TOP_POSTERS:
50+
is_valid = await poster_ratings_factory.top_posters_service.validate_api_key(payload.api_key.strip())
51+
else:
52+
raise HTTPException(status_code=400, detail=f"Unsupported provider: {payload.provider}")
53+
54+
if is_valid:
55+
return BaseValidationResponse(valid=True, message="API key is valid")
56+
return BaseValidationResponse(valid=False, message="Invalid API key")
57+
except Exception as e:
58+
logger.error(f"Validation failed: {str(e)}")
59+
raise HTTPException(status_code=500, detail="Validation failed due to an internal error.")
60+
61+
62+
@router.post("/simkl/validation")
63+
async def validate_simkl_api_key(data: BaseValidationInput) -> BaseValidationResponse:
64+
try:
65+
response = await simkl_service.get_trending(data.api_key)
66+
if response:
67+
return BaseValidationResponse(valid=True, message="Valid API Key")
68+
return BaseValidationResponse(valid=False, message="Invalid API Key")
69+
except Exception as e:
70+
logger.error(f"Validation failed: {str(e)}")
71+
raise HTTPException(status_code=500, detail="Validation failed due to an internal error.")

app/api/models/stats.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from pydantic import BaseModel
2+
3+
4+
class StatsResponse(BaseModel):
5+
total_users: int

app/api/models/validation.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from pydantic import BaseModel, Field
2+
3+
4+
class BaseValidationInput(BaseModel):
5+
api_key: str = Field(description="API key to validate")
6+
7+
8+
class BaseValidationResponse(BaseModel):
9+
valid: bool
10+
message: str
11+
12+
13+
class PosterRatingValidationInput(BaseValidationInput):
14+
provider: str = Field(description="Provider name: 'rpdb' or 'top_posters'")

app/api/main.py renamed to app/api/router.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
from .endpoints.health import router as health_router
66
from .endpoints.manifest import router as manifest_router
77
from .endpoints.meta import router as meta_router
8-
from .endpoints.poster_rating import router as poster_rating_router
98
from .endpoints.stats import router as stats_router
109
from .endpoints.tokens import router as tokens_router
10+
from .endpoints.validation import router as validation_router
1111

1212
api_router = APIRouter()
1313

@@ -24,4 +24,4 @@ async def root():
2424
api_router.include_router(meta_router)
2525
api_router.include_router(announcement_router)
2626
api_router.include_router(stats_router)
27-
api_router.include_router(poster_rating_router)
27+
api_router.include_router(validation_router)

app/core/app.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from loguru import logger
1212

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

109+
# Get total users count
110+
total_users = 0
111+
try:
112+
total_users = await token_store.count_users()
113+
except Exception as e:
114+
logger.warning(f"Failed to get total users for template: {e}")
115+
109116
# Format default catalogs for frontend
110117
default_catalogs = get_default_catalogs_for_frontend()
111118

@@ -117,6 +124,7 @@ async def configure_page(request: Request, _token: str | None = None):
117124
html_content = template.render(
118125
request=request,
119126
app_version=__version__,
127+
total_users=total_users,
120128
app_host=settings.HOST_NAME,
121129
announcement_html=settings.ANNOUNCEMENT_HTML or "",
122130
languages=languages,

app/core/base_client.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,28 +37,37 @@ async def _request(self, method: str, url: str, max_tries: int | None = None, **
3737
"""Internal request handler with retry logic."""
3838
client = await self.get_client()
3939
tries = max_tries or self.max_retries
40-
last_exception = None
4140

4241
for attempt in range(1, tries + 1):
4342
try:
4443
response = await client.request(method, url, **kwargs)
4544
response.raise_for_status()
4645
return response
4746
except (httpx.HTTPStatusError, httpx.RequestError) as e:
48-
last_exception = e
49-
if attempt < tries:
47+
48+
# Check if the error is retryable
49+
is_retryable = True
50+
if isinstance(e, httpx.HTTPStatusError):
51+
# Only retry on 429 (Rate Limit) and 5xx (Server Errors)
52+
# 404, 400, 401, etc. are not retryable
53+
is_retryable = e.response.status_code in (429, 500, 502, 503, 504)
54+
55+
if is_retryable and attempt < tries:
5056
wait_time = 0.5 * (2 ** (attempt - 1)) # Exponential backoff
5157
logger.warning(
5258
f"Request failed ({method} {url}): {str(e)}. "
5359
f"Retrying in {wait_time}s... (Attempt {attempt}/{tries})"
5460
)
5561
await asyncio.sleep(wait_time)
5662
else:
57-
logger.error(f"Request failed after {tries} attempts: {str(e)}")
63+
# If not retryable or no more attempts left, log and raise
64+
if not is_retryable:
65+
logger.error(f"Non-retryable request failure ({method} {url}): {str(e)}")
66+
else:
67+
logger.error(f"Request failed after {tries} attempts ({method} {url}): {str(e)}")
68+
raise e
5869

59-
if last_exception:
60-
raise last_exception
61-
raise httpx.RequestError("Request failed for unknown reasons")
70+
raise httpx.RequestError(f"Request failed for {method} {url} with 0 attempts configured")
6271

6372
async def get(self, url: str, params: dict[str, Any] | None = None, **kwargs) -> dict[str, Any]:
6473
"""Perform a GET request and return the JSON response."""

0 commit comments

Comments
 (0)