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
45 changes: 45 additions & 0 deletions app/api/endpoints/poster_rating.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from fastapi import APIRouter, HTTPException
from loguru import logger
from pydantic import BaseModel, Field

from app.services.poster_ratings.factory import PosterProvider, poster_ratings_factory

router = APIRouter(prefix="/poster-rating", tags=["poster-rating"])


class ValidateApiKeyRequest(BaseModel):
provider: str = Field(description="Provider name: 'rpdb' or 'top_posters'")
api_key: str = Field(description="API key to validate")


class ValidateApiKeyResponse(BaseModel):
valid: bool
message: str | None = None


@router.post("/validate", response_model=ValidateApiKeyResponse)
async def validate_api_key(payload: ValidateApiKeyRequest) -> ValidateApiKeyResponse:
"""Validate a poster rating provider API key."""
if not payload.api_key or not payload.api_key.strip():
return ValidateApiKeyResponse(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 ValidateApiKeyResponse(valid=True, message="API key is valid")
else:
return ValidateApiKeyResponse(valid=False, message="Invalid API key")
except Exception as e:
logger.error(f"Validation failed: {str(e)}")
return ValidateApiKeyResponse(valid=False, message="Validation failed due to an internal error.")
7 changes: 4 additions & 3 deletions app/api/endpoints/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from app.core.config import settings
from app.core.security import redact_token
from app.core.settings import CatalogConfig, UserSettings, get_default_settings
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
Expand All @@ -20,7 +20,7 @@ class TokenRequest(BaseModel):
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")
rpdb_key: str | None = Field(default=None, description="Optional RPDB API Key")
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")

Expand Down Expand Up @@ -78,10 +78,11 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse

# 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,
rpdb_key=payload.rpdb_key.strip() if payload.rpdb_key else None,
poster_rating=poster_rating,
excluded_movie_genres=payload.excluded_movie_genres,
excluded_series_genres=payload.excluded_series_genres,
)
Expand Down
2 changes: 2 additions & 0 deletions app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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

Expand All @@ -23,3 +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)
11 changes: 10 additions & 1 deletion app/core/settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from pydantic import BaseModel, Field

from app.services.poster_ratings.factory import PosterProvider


class CatalogConfig(BaseModel):
id: str # "watchly.rec", "watchly.theme", "watchly.item"
Expand All @@ -11,10 +13,17 @@ class CatalogConfig(BaseModel):
shuffle: bool = Field(default=False, description="Randomize order of items in this catalog")


class PosterRatingConfig(BaseModel):
"""Configuration for poster rating provider."""

provider: PosterProvider = Field(description="Provider name: 'rpdb' or 'top_posters'")
api_key: str = Field(description="API key for the provider")


class UserSettings(BaseModel):
catalogs: list[CatalogConfig]
language: str = "en-US"
rpdb_key: str | None = None
poster_rating: PosterRatingConfig | None = Field(default=None, description="Poster rating provider configuration")
excluded_movie_genres: list[str] = Field(default_factory=list)
excluded_series_genres: list[str] = Field(default_factory=list)

Expand Down
34 changes: 34 additions & 0 deletions app/services/poster_ratings/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from enum import Enum
from typing import Literal

from app.services.poster_ratings.rpdb import RPDBService
from app.services.poster_ratings.top_posters import TopPostersService


class PosterProvider(Enum):
RPDB = "rpdb"
TOP_POSTERS = "top_posters"


class PosterRatingsFactory:
def __init__(self):
self.rpdb_service: RPDBService = RPDBService()
self.top_posters_service: TopPostersService = TopPostersService()

def get_poster_url(
self,
poster_provider: PosterProvider,
api_key: str,
provider: Literal["imdb", "tmdb", "tvdb"],
item_id: str,
**kwargs,
) -> str:

poster_provider_map = {
PosterProvider.RPDB: self.rpdb_service,
PosterProvider.TOP_POSTERS: self.top_posters_service,
}
return poster_provider_map[poster_provider].get_poster(api_key, provider, item_id, **kwargs)


poster_ratings_factory = PosterRatingsFactory()
28 changes: 28 additions & 0 deletions app/services/poster_ratings/rpdb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from typing import Literal
from urllib.parse import urlencode

import httpx


class RPDBService:
def __init__(self):
self.base_url = "https://api.ratingposterdb.com"

async def validate_api_key(self, api_key: str) -> bool:
url = f"{self.base_url}/{api_key}/isValid"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
return response.status_code == 200

def get_poster_url(
self,
api_key: str,
provider: Literal["imdb", "tmdb", "tvdb"],
item_id: str,
fallback: str,
) -> str:
url = f"{self.base_url}/{api_key}/{provider}/poster-default/{item_id}.jpg"
params = {"fallback": "true"}

poster_url = f"{url}?{urlencode(params)}"
return poster_url
23 changes: 23 additions & 0 deletions app/services/poster_ratings/top_posters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Literal
from urllib.parse import urlencode

import httpx


class TopPostersService:
def __init__(self):
self.base_url = "https://api.top-streaming.stream"

async def validate_api_key(self, api_key: str) -> bool:
url = f"{self.base_url}/auth/verify/{api_key}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
json_data = response.json()
return json_data.get("valid", False)

def get_poster_url(self, api_key: str, provider: Literal["imdb", "tmdb", "tvdb"], item_id: str, **kwargs) -> str:
url = f"{self.base_url}/{api_key}/{provider}/poster-default/{item_id}.jpg"

poster_url = f"{url}?{urlencode(kwargs)}"
return poster_url
24 changes: 18 additions & 6 deletions app/services/recommendation/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from loguru import logger

from app.core.constants import DEFAULT_CONCURRENCY_LIMIT
from app.services.rpdb import RPDBService
from app.services.poster_ratings.factory import PosterProvider, poster_ratings_factory


class RecommendationMetadata:
Expand Down Expand Up @@ -93,12 +93,24 @@ async def format_for_stremio(
return meta_data

@staticmethod
def _get_poster_url(details: dict, stremio_id: str, user_settings: Any) -> str | None:
"""Resolve poster URL using RPDB if configured, otherwise TMDB."""
if user_settings and user_settings.rpdb_key:
return RPDBService.get_poster_url(user_settings.rpdb_key, stremio_id)
def _get_poster_url(details: dict, item_id: str, user_settings: Any) -> str | None:
"""Resolve poster URL using poster rating provider if configured, otherwise TMDB."""
path = details.get("poster_path")
return f"https://image.tmdb.org/t/p/w500{path}" if path else None
poster_url = f"https://image.tmdb.org/t/p/w500{path}"

if user_settings:
poster_rating = user_settings.poster_rating
if poster_rating and poster_rating.api_key:
try:
provider_enum = PosterProvider(poster_rating.provider)
poster_url = poster_ratings_factory.get_poster_url(
provider_enum, poster_rating.api_key, "imdb", item_id, fallback=poster_url
)
except ValueError as e:
logger.warning(f"Error getting poster URL for item ID {item_id}: {e}")
pass

return poster_url

@staticmethod
def _get_backdrop_url(details: dict) -> str | None:
Expand Down
4 changes: 2 additions & 2 deletions app/services/tmdb/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ async def find_by_imdb_id(self, imdb_id: str) -> tuple[int | None, str | None]:
logger.exception(f"Error finding TMDB ID for IMDB {imdb_id}: {e}")
return None, None

@alru_cache(maxsize=500)
@alru_cache(maxsize=500, ttl=86400)
async def get_movie_details(self, movie_id: int) -> dict[str, Any]:
"""Get details of a specific movie with credits and keywords."""
params = {"append_to_response": "credits,external_ids,keywords"}
return await self.client.get(f"/movie/{movie_id}", params=params)

@alru_cache(maxsize=500)
@alru_cache(maxsize=500, ttl=86400)
async def get_tv_details(self, tv_id: int) -> dict[str, Any]:
"""Get details of a specific TV series with credits and keywords."""
params = {"append_to_response": "credits,external_ids,keywords"}
Expand Down
85 changes: 85 additions & 0 deletions app/services/token_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ async def store_user_data(self, user_id: str, payload: dict[str, Any]) -> str:
# Do not store plaintext passwords
raise RuntimeError("PASSWORD_ENCRYPT_FAILED")

# Encrypt poster_rating API key if present
if storage_data.get("settings") and isinstance(storage_data["settings"], dict):
poster_rating = storage_data["settings"].get("poster_rating")
if poster_rating and isinstance(poster_rating, dict) and poster_rating.get("api_key"):
try:
# Only encrypt if it's not already encrypted (check if it's a valid encrypted string)
api_key = poster_rating["api_key"]
# Simple check: encrypted tokens are base64-like and longer
# If it looks like plaintext, encrypt it
# Fernet encrypted tokens start with "gAAAAAB"
if not api_key.startswith("gAAAAAB"):
poster_rating["api_key"] = self.encrypt_token(api_key)
except Exception as exc:
logger.warning(f"Failed to encrypt poster_rating api_key for {redact_token(user_id)}: {exc}")
json_str = json.dumps(storage_data)

if settings.TOKEN_TTL_SECONDS and settings.TOKEN_TTL_SECONDS > 0:
Expand Down Expand Up @@ -124,6 +138,60 @@ async def update_user_data(self, token: str, payload: dict[str, Any]) -> str:
user_id = self.get_user_id_from_token(token)
return await self.store_user_data(user_id, payload)

async def _migrate_poster_rating_format_raw(self, token: str, redis_key: str, data: dict) -> dict | None:
"""Migrate old rpdb_key format to new poster_rating format in raw Redis data if needed."""
if not data:
return None

settings_dict = data.get("settings")
if not settings_dict or not isinstance(settings_dict, dict):
return None

rpdb_key = settings_dict.get("rpdb_key")
poster_rating = settings_dict.get("poster_rating")
needs_save = False

# Case 1: Migrate rpdb_key to poster_rating if rpdb_key exists and poster_rating doesn't
if rpdb_key and not poster_rating:
logger.info(f"[MIGRATION] Migrating rpdb_key to poster_rating format for {redact_token(token)}")
settings_dict["poster_rating"] = {
"provider": "rpdb",
"api_key": self.encrypt_token(rpdb_key), # Encrypt the API key
}
needs_save = True

# Case 2: Clean up deprecated rpdb_key field if it exists (even if empty/null)
# Remove it since we've migrated to poster_rating or it's no longer needed
if "rpdb_key" in settings_dict:
settings_dict.pop("rpdb_key")
if not needs_save: # Only log if we didn't already log migration
logger.info(f"[MIGRATION] Removing deprecated rpdb_key field for {redact_token(token)}")
needs_save = True

# Save back to redis if any changes were made
if needs_save:
try:
if settings.TOKEN_TTL_SECONDS and settings.TOKEN_TTL_SECONDS > 0:
await redis_service.set(redis_key, json.dumps(data), settings.TOKEN_TTL_SECONDS)
else:
await redis_service.set(redis_key, json.dumps(data))

# Invalidate cache so next read gets the migrated data
try:
self.get_user_data.cache_invalidate(token)
except Exception:
pass

logger.info(
"[MIGRATION] Successfully migrated and encrypted poster_rating " f"format for {redact_token(token)}"
)
return data
except Exception as e:
logger.warning(f"[MIGRATION] Failed to save migrated data for {redact_token(token)}: {e}")
return None

return None

@alru_cache(maxsize=2000, ttl=43200)
async def get_user_data(self, token: str) -> dict[str, Any] | None:
# Short-circuit for tokens known to be missing
Expand Down Expand Up @@ -151,6 +219,10 @@ async def get_user_data(self, token: str) -> dict[str, Any] | None:
except json.JSONDecodeError:
return None

updated_data = await self._migrate_poster_rating_format_raw(token, key, data)
if updated_data:
data = updated_data

# Decrypt fields individually; do not fail entire record on decryption errors
if data.get("authKey"):
try:
Expand All @@ -166,6 +238,19 @@ async def get_user_data(self, token: str) -> dict[str, Any] | None:
logger.warning(f"Decryption failed for password associated with {redact_token(token)}: {e}")
# require re-login path when needed
data["password"] = None

# Decrypt poster_rating API key if present
if data.get("settings") and isinstance(data["settings"], dict):
poster_rating = data["settings"].get("poster_rating")
if poster_rating and isinstance(poster_rating, dict) and poster_rating.get("api_key"):
try:
poster_rating["api_key"] = self.decrypt_token(poster_rating["api_key"])
except Exception as e:
logger.warning(
"Decryption failed for poster_rating api_key associated " f"with {redact_token(token)}: {e}"
)
poster_rating["api_key"] = None

return data

async def delete_token(self, token: str = None, key: str = None) -> None:
Expand Down
Loading