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
2 changes: 0 additions & 2 deletions app/api/endpoints/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

@router.get("/manifest.json")
async def manifest():
"""Get base manifest for unauthenticated users."""
manifest = manifest_service.get_base_manifest()
# since user is not logged in, return empty catalogs
manifest["catalogs"] = []
Expand All @@ -16,5 +15,4 @@ async def manifest():

@router.get("/{token}/manifest.json")
async def manifest_token(token: str):
"""Get manifest for authenticated user."""
return await manifest_service.get_manifest_for_token(token)
231 changes: 22 additions & 209 deletions app/api/endpoints/tokens.py
Original file line number Diff line number Diff line change
@@ -1,229 +1,42 @@
from datetime import datetime, timezone
from typing import Literal

from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from loguru import logger
from pydantic import BaseModel, Field

from app.core.config import settings
from app.core.security import redact_token
from app.core.settings import CatalogConfig, PosterRatingConfig, UserSettings, get_default_settings
from app.services.manifest import manifest_service
from app.services.stremio.service import StremioBundle
from app.services.token_store import token_store

router = APIRouter(prefix="/tokens", tags=["tokens"])


class TokenRequest(BaseModel):
authKey: str | None = Field(default=None, description="Stremio auth key")
email: str | None = Field(default=None, description="Stremio account email")
password: str | None = Field(default=None, description="Stremio account password (stored securely)")
catalogs: list[CatalogConfig] | None = Field(default=None, description="Optional catalog configuration")
language: str = Field(default="en-US", description="Language for TMDB API")
poster_rating: PosterRatingConfig | None = Field(default=None, description="Poster rating provider configuration")
excluded_movie_genres: list[str] = Field(default_factory=list, description="List of movie genre IDs to exclude")
excluded_series_genres: list[str] = Field(default_factory=list, description="List of series genre IDs to exclude")
popularity: Literal["mainstream", "balanced", "gems", "all"] = Field(
default="balanced", description="Popularity for TMDB API"
)
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)"
)

from app.api.models.tokens import TokenRequest, TokenResponse
from app.services.auth import auth_service

class TokenResponse(BaseModel):
token: str
manifestUrl: str
expiresInSeconds: int | None = Field(
default=None,
description="Number of seconds before the token expires (None means it does not expire)",
)


async def _verify_credentials_or_raise(bundle: StremioBundle, auth_key: str) -> str:
"""Ensure the supplied auth key is valid."""
try:
await bundle.auth.get_user_info(auth_key)
return auth_key
except Exception as exc:
raise HTTPException(
status_code=400,
detail="Invalid Stremio auth key.",
) from exc
router = APIRouter(prefix="/tokens", tags=["Tokens"])


@router.post("/", response_model=TokenResponse)
async def create_token(payload: TokenRequest, request: Request) -> TokenResponse:
# Prefer email+password if provided; else require authKey
email = (payload.email or "").strip() or None
password = (payload.password or "").strip() or None
stremio_auth_key = (payload.authKey or "").strip() or None

if not (email and password) and not stremio_auth_key:
raise HTTPException(status_code=400, detail="Provide email+password or a valid Stremio auth key.")

# Remove quotes if present for authKey
if stremio_auth_key and stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'):
stremio_auth_key = stremio_auth_key[1:-1].strip()

bundle = StremioBundle()
# 1. Establish a valid auth key and fetch user info
if email and password:
stremio_auth_key = await bundle.auth.login(email, password)

async def create_token(payload: TokenRequest) -> TokenResponse:
try:
user_info = await bundle.auth.get_user_info(stremio_auth_key)
user_id = user_info["user_id"]
resolved_email = user_info.get("email", "")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to verify Stremio identity: {e}")

# 2. Check if user already exists
token = token_store.get_token_from_user_id(user_id)
existing_data = await token_store.get_user_data(token)

# 3. Construct Settings
default_settings = get_default_settings()
poster_rating = payload.poster_rating
user_settings = UserSettings(
language=payload.language or default_settings.language,
catalogs=payload.catalogs if payload.catalogs else default_settings.catalogs,
poster_rating=poster_rating,
excluded_movie_genres=payload.excluded_movie_genres,
excluded_series_genres=payload.excluded_series_genres,
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
payload_to_store = {
"authKey": stremio_auth_key,
"email": resolved_email or email or "",
"settings": user_settings.model_dump(),
}
if existing_data:
payload_to_store["last_updated"] = existing_data.get("last_updated")
else:
payload_to_store["last_updated"] = datetime.now(timezone.utc).isoformat()

if email and password:
payload_to_store["password"] = password

# 5. Store user data
token = await token_store.store_user_data(user_id, payload_to_store)
account_status = "updated" if existing_data else "created"
logger.info(f"[{redact_token(token)}] Account {account_status} for user {user_id}")

# 6. Cache library items and profiles before returning
# This ensures manifest generation is fast when user installs the addon
# We wait for caching to complete so everything is ready immediately
try:
logger.info(f"[{redact_token(token)}] Caching library and profiles before returning token")
await manifest_service.cache_library_and_profiles(bundle, stremio_auth_key, user_settings, token)
logger.info(f"[{redact_token(token)}] Successfully cached library and profiles")
except Exception as e:
logger.warning(
f"[{redact_token(token)}] Failed to cache library and profiles: {e}. "
"Continuing anyway - will cache on manifest request."
)
# Continue even if caching fails - manifest service will handle it

base_url = settings.HOST_NAME
manifest_url = f"{base_url}/{token}/manifest.json"
expires_in = settings.TOKEN_TTL_SECONDS if settings.TOKEN_TTL_SECONDS > 0 else None

await bundle.close()

return TokenResponse(
token=token,
manifestUrl=manifest_url,
expiresInSeconds=expires_in,
)


async def get_stremio_user_data(payload: TokenRequest) -> tuple[str, str]:
bundle = StremioBundle()
try:
email = (payload.email or "").strip() or None
password = (payload.password or "").strip() or None
auth_key = (payload.authKey or "").strip() or None

if email and password:
try:
auth_key = await bundle.auth.login(email, password)
user_info = await bundle.auth.get_user_info(auth_key)
return user_info["user_id"], user_info.get("email", email)
except Exception as e:
logger.error(f"Stremio identity check failed: {e}")
raise HTTPException(status_code=400, detail="Failed to verify Stremio identity.")
elif auth_key:
if auth_key.startswith('"') and auth_key.endswith('"'):
auth_key = auth_key[1:-1].strip()
try:
user_info = await bundle.auth.get_user_info(auth_key)
return user_info["user_id"], user_info.get("email", "")
except Exception as e:
logger.error(f"Stremio identity check failed: {e}")
raise HTTPException(status_code=400, detail="Invalid Stremio auth key.")
else:
raise HTTPException(status_code=400, detail="Credentials required.")
finally:
await bundle.close()
return await auth_service.create_user_token(payload)
except HTTPException:
raise
except Exception as exc:
logger.exception(f"Token creation failed: {exc}")
raise HTTPException(status_code=503, detail="Storage temporarily unavailable.")


@router.post("/stremio-identity", status_code=200)
async def check_stremio_identity(payload: TokenRequest):
"""Fetch user info from Stremio and check if account exists."""
user_id, email = await get_stremio_user_data(payload)
try:
token = token_store.get_token_from_user_id(user_id)
user_data = await token_store.get_user_data(token)
exists = bool(user_data)
except Exception:
exists = False
user_data = None

response = {"user_id": user_id, "email": email, "exists": exists}
if exists and user_data:
# 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
return await auth_service.get_identity_with_settings(payload)
except HTTPException:
raise
except Exception as exc:
logger.exception(f"Identity check failed: {exc}")
raise HTTPException(status_code=503, detail="Service temporarily unavailable.")


@router.delete("/", status_code=200)
async def delete_redis_token(payload: TokenRequest):
"""Delete a token based on Stremio credentials."""
try:
user_id, _ = await get_stremio_user_data(payload)
token = token_store.get_token_from_user_id(user_id)
existing_data = await token_store.get_user_data(token)
if not existing_data:
raise HTTPException(status_code=404, detail="Account not found.")

await token_store.delete_token(token)
logger.info(f"[{redact_token(token)}] Token deleted for user {user_id}")
return {"detail": "Settings deleted successfully"}
await auth_service.delete_user_account(payload)
return JSONResponse(status_code=200, content="Settings deleted successfully")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The response for this delete endpoint has changed from a JSON object to a raw string. Previously, it returned {"detail": "Settings deleted successfully"}. The new implementation returns the string "Settings deleted successfully". This is a breaking change for any client expecting a JSON object with keys.

To maintain API consistency and avoid breaking clients, it's better to return a JSON object.

Suggested change
return JSONResponse(status_code=200, content="Settings deleted successfully")
return {"message": "Settings deleted successfully"}

except HTTPException:
raise
except Exception as exc:
logger.error(f"Token deletion failed: {exc}")
raise HTTPException(status_code=503, detail="Storage temporarily unavailable.")
logger.exception(f"Account deletion failed: {exc}")
raise HTTPException(status_code=503, detail="Service temporarily unavailable.")
36 changes: 36 additions & 0 deletions app/api/models/tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import Literal

from pydantic import BaseModel, Field

from app.core.settings import CatalogConfig, PosterRatingConfig


class TokenRequest(BaseModel):
authKey: str | None = Field(default=None, description="Stremio auth key")
email: str | None = Field(default=None, description="Stremio account email")
password: str | None = Field(default=None, description="Stremio account password")
catalogs: list[CatalogConfig] | None = Field(default=None, description="Catalog configuration")
language: str = Field(default="en-US", description="Language for TMDB API")
poster_rating: PosterRatingConfig | None = Field(default=None, description="Poster rating provider configuration")
excluded_movie_genres: list[str] = Field(default_factory=list, description="List of movie genre IDs to exclude")
excluded_series_genres: list[str] = Field(default_factory=list, description="List of series genre IDs to exclude")
popularity: Literal["mainstream", "balanced", "gems", "all"] = Field(
default="balanced", description="Popularity for TMDB API"
)
year_min: int = Field(default=2010, description="Minimum release year for TMDB API")
year_max: int = Field(default=2026, description="Maximum release year for TMDB API")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The default value for year_max has been changed from 2025 to 2026. This is a silent change in behavior. If this is intentional, it's fine, but it's worth noting as it affects the default filtering logic.

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")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The description for tmdb_api_key has lost some useful context. The previous description, TMDB API Key (required for new clients if server has none), was more informative for developers using the API. It would be beneficial to restore it.

Suggested change
tmdb_api_key: str | None = Field(default=None, description="TMDB API Key")
tmdb_api_key: str | None = Field(default=None, description="TMDB API Key (required for new clients if server has none)")



class TokenResponse(BaseModel):
token: str
manifestUrl: str
expiresInSeconds: int | None = Field(
default=None,
description="Number of seconds before the token expires (None means it does not expire)",
)
Loading