diff --git a/app/api/endpoints/poster_rating.py b/app/api/endpoints/poster_rating.py new file mode 100644 index 0000000..d9fc1db --- /dev/null +++ b/app/api/endpoints/poster_rating.py @@ -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.") diff --git a/app/api/endpoints/tokens.py b/app/api/endpoints/tokens.py index 351952c..075f0da 100644 --- a/app/api/endpoints/tokens.py +++ b/app/api/endpoints/tokens.py @@ -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 @@ -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") @@ -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, ) diff --git a/app/api/main.py b/app/api/main.py index 489b4ec..784b8eb 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -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 @@ -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) diff --git a/app/core/settings.py b/app/core/settings.py index 46f1e54..9692f46 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -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" @@ -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) diff --git a/app/services/poster_ratings/factory.py b/app/services/poster_ratings/factory.py new file mode 100644 index 0000000..4261bd5 --- /dev/null +++ b/app/services/poster_ratings/factory.py @@ -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() diff --git a/app/services/poster_ratings/rpdb.py b/app/services/poster_ratings/rpdb.py new file mode 100644 index 0000000..1e902f1 --- /dev/null +++ b/app/services/poster_ratings/rpdb.py @@ -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 diff --git a/app/services/poster_ratings/top_posters.py b/app/services/poster_ratings/top_posters.py new file mode 100644 index 0000000..e112642 --- /dev/null +++ b/app/services/poster_ratings/top_posters.py @@ -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 diff --git a/app/services/recommendation/metadata.py b/app/services/recommendation/metadata.py index f8d18a5..9979186 100644 --- a/app/services/recommendation/metadata.py +++ b/app/services/recommendation/metadata.py @@ -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: @@ -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: diff --git a/app/services/tmdb/service.py b/app/services/tmdb/service.py index f78b3f6..5de94d9 100644 --- a/app/services/tmdb/service.py +++ b/app/services/tmdb/service.py @@ -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"} diff --git a/app/services/token_store.py b/app/services/token_store.py index f716f57..6ba20f2 100644 --- a/app/services/token_store.py +++ b/app/services/token_store.py @@ -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: @@ -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 @@ -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: @@ -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: diff --git a/app/static/js/modules/auth.js b/app/static/js/modules/auth.js index 13e0c1d..b62c2e1 100644 --- a/app/static/js/modules/auth.js +++ b/app/static/js/modules/auth.js @@ -276,7 +276,25 @@ async function fetchStremioIdentity(authKey) { if (data.settings) { const s = data.settings; if (s.language && languageSelect) languageSelect.value = s.language; - if (s.rpdb_key && document.getElementById('rpdbKey')) document.getElementById('rpdbKey').value = s.rpdb_key; + + // Handle poster rating: prefer new format, fallback to old rpdb_key + const posterRatingProvider = document.getElementById('posterRatingProvider'); + const posterRatingApiKey = document.getElementById('posterRatingApiKey'); + if (posterRatingProvider && posterRatingApiKey) { + if (s.poster_rating && s.poster_rating.provider && s.poster_rating.api_key) { + // New format + posterRatingProvider.value = s.poster_rating.provider; + posterRatingApiKey.value = s.poster_rating.api_key; + // Trigger change event to show/hide fields + posterRatingProvider.dispatchEvent(new Event('change')); + } else if (s.rpdb_key) { + // Old format - migrate to new format in UI + posterRatingProvider.value = 'rpdb'; + posterRatingApiKey.value = s.rpdb_key; + // Trigger change event to show/hide fields + posterRatingProvider.dispatchEvent(new Event('change')); + } + } // Genres (Checked = Excluded) document.querySelectorAll('input[name="movie-genre"]').forEach(cb => cb.checked = false); @@ -301,6 +319,8 @@ async function fetchStremioIdentity(authKey) { if (remote.name) local.name = remote.name; if (typeof remote.enabled_movie === 'boolean') local.enabledMovie = remote.enabled_movie; if (typeof remote.enabled_series === 'boolean') local.enabledSeries = remote.enabled_series; + if (typeof remote.display_at_home === 'boolean') local.display_at_home = remote.display_at_home; + if (typeof remote.shuffle === 'boolean') local.shuffle = remote.shuffle; } }); if (renderCatalogList) renderCatalogList(); diff --git a/app/static/js/modules/catalog.js b/app/static/js/modules/catalog.js index 18855c1..d35790c 100644 --- a/app/static/js/modules/catalog.js +++ b/app/static/js/modules/catalog.js @@ -131,17 +131,17 @@ function createCatalogItem(cat, index) {
-
${escapeHtml(cat.description || '')}
+
${escapeHtml(cat.description || '')}
-
- - -
@@ -204,11 +204,11 @@ function createCatalogItem(cat, index) { // Update UI allTypeButtons.forEach(b => { - b.classList.remove('bg-white', 'text-black', 'shadow-sm', 'hover:text-black'); - b.classList.add('text-slate-400', 'hover:text-white'); + b.classList.remove('bg-white/10', 'text-white', 'border-white/20', 'shadow-sm'); + b.classList.add('text-slate-400', 'hover:text-white', 'hover:bg-white/5', 'border-transparent'); }); - e.target.classList.remove('text-slate-400', 'hover:text-white'); - e.target.classList.add('bg-white', 'text-black', 'shadow-sm', 'hover:text-black'); + e.target.classList.remove('text-slate-400', 'hover:text-white', 'hover:bg-white/5', 'border-transparent'); + e.target.classList.add('bg-white/10', 'text-white', 'border-white/20', 'shadow-sm'); }); }); diff --git a/app/static/js/modules/form.js b/app/static/js/modules/form.js index f8dcb5c..070441c 100644 --- a/app/static/js/modules/form.js +++ b/app/static/js/modules/form.js @@ -31,6 +31,7 @@ export function initializeForm(domElements, catalogState) { initializeLanguageSelect(); initializePasswordToggles(); initializeSuccessActions(); + initializePosterRatingProvider(); } // Form Submission @@ -45,7 +46,8 @@ async function initializeFormSubmission() { const email = emailInput?.value.trim(); const password = passwordInput?.value; const language = languageSelect.value; - const rpdbKey = document.getElementById("rpdbKey").value.trim(); + const posterRatingProvider = document.getElementById("posterRatingProvider")?.value || ""; + const posterRatingApiKey = document.getElementById("posterRatingApiKey")?.value.trim() || ""; const excludedMovieGenres = Array.from(document.querySelectorAll('input[name="movie-genre"]:checked')).map(cb => cb.value); const excludedSeriesGenres = Array.from(document.querySelectorAll('input[name="series-genre"]:checked')).map(cb => cb.value); @@ -98,16 +100,35 @@ async function initializeFormSubmission() { return; } + // Validate poster rating API key if provided + if (posterRatingProvider && posterRatingApiKey) { + if (window.validatePosterRatingApiKey) { + const isValid = await window.validatePosterRatingApiKey(); + if (!isValid) { + return; + } + } + } + setLoading(true); try { + // Build poster_rating payload + let posterRating = null; + if (posterRatingProvider && posterRatingApiKey) { + posterRating = { + provider: posterRatingProvider, + api_key: posterRatingApiKey + }; + } + const payload = { authKey: sAuthKey || undefined, email: email || undefined, password: password || undefined, catalogs: catalogsToSend, language: language, - rpdb_key: rpdbKey, + poster_rating: posterRating, excluded_movie_genres: excludedMovieGenres, excluded_series_genres: excludedSeriesGenres }; @@ -160,6 +181,154 @@ function initializeLanguageSelect() { if (!languageSelect) return; } +// Poster Rating Provider +function initializePosterRatingProvider() { + const providerSelect = document.getElementById("posterRatingProvider"); + const apiKeyContainer = document.getElementById("posterRatingApiKeyContainer"); + const apiKeyInput = document.getElementById("posterRatingApiKey"); + const helpContainer = document.getElementById("posterRatingHelp"); + const helpText = document.getElementById("posterRatingHelpText"); + const validateBtn = document.getElementById("posterRatingApiKeyValidate"); + const toggleBtn = document.getElementById("posterRatingApiKeyToggle"); + const eyeIcon = document.getElementById("posterRatingApiKeyEye"); + const eyeOffIcon = document.getElementById("posterRatingApiKeyEyeOff"); + const validationMessage = document.getElementById("posterRatingValidationMessage"); + + if (!providerSelect || !apiKeyContainer || !apiKeyInput || !helpContainer || !helpText) return; + + const providerInfo = { + "rpdb": { + name: "RPDB (RatingPosterDB)", + url: "https://ratingposterdb.com", + description: "Enable ratings on posters via RatingPosterDB" + }, + "top_posters": { + name: "Top Posters", + url: "https://api.top-streaming.stream/", + description: "Enable ratings on posters via Top Posters" + } + }; + + let isValidated = false; + + // Eye toggle functionality + if (toggleBtn && eyeIcon && eyeOffIcon) { + toggleBtn.addEventListener("click", () => { + const isPassword = apiKeyInput.type === "password"; + apiKeyInput.type = isPassword ? "text" : "password"; + eyeIcon.classList.toggle("hidden", !isPassword); + eyeOffIcon.classList.toggle("hidden", isPassword); + }); + } + + // Validation function + async function validateApiKey() { + const selectedProvider = providerSelect.value; + const apiKey = apiKeyInput.value.trim(); + + if (!selectedProvider || !apiKey) { + showValidationMessage("Please select a provider and enter an API key", "error"); + return false; + } + + if (!validateBtn) return false; + + // Show loading state + validateBtn.disabled = true; + validateBtn.classList.add("opacity-50", "cursor-not-allowed"); + const originalHTML = validateBtn.innerHTML; + validateBtn.innerHTML = ''; + + try { + const response = await fetch("/poster-rating/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: selectedProvider, api_key: apiKey }) + }); + + const data = await response.json(); + + if (data.valid) { + showValidationMessage("API key is valid ✓", "success"); + isValidated = true; + return true; + } else { + showValidationMessage(data.message || "Invalid API key", "error"); + apiKeyInput.value = ""; // Clear invalid key + isValidated = false; + return false; + } + } catch (error) { + showValidationMessage("Validation failed. Please try again.", "error"); + isValidated = false; + return false; + } finally { + validateBtn.disabled = false; + validateBtn.classList.remove("opacity-50", "cursor-not-allowed"); + validateBtn.innerHTML = originalHTML; + } + } + + // Show validation message + function showValidationMessage(message, type) { + if (!validationMessage) return; + validationMessage.textContent = message; + validationMessage.className = `mt-2 text-xs ${type === "success" ? "text-green-400" : "text-red-400"}`; + validationMessage.classList.remove("hidden"); + } + + // Clear validation message + function clearValidationMessage() { + if (validationMessage) { + validationMessage.classList.add("hidden"); + } + } + + // Validate button click + if (validateBtn) { + validateBtn.addEventListener("click", validateApiKey); + } + + // Clear validation when API key changes + apiKeyInput.addEventListener("input", () => { + isValidated = false; + clearValidationMessage(); + }); + + function updateUI() { + const selectedProvider = providerSelect.value; + + if (selectedProvider && providerInfo[selectedProvider]) { + const info = providerInfo[selectedProvider]; + apiKeyContainer.style.display = "block"; + helpContainer.style.display = "block"; + helpText.innerHTML = `${info.description}. Get your API key from ${info.name}.`; + // Don't clear the API key when switching providers - just reset validation + isValidated = false; + clearValidationMessage(); + } else { + // Only clear when provider is set to "None" + apiKeyContainer.style.display = "none"; + helpContainer.style.display = "none"; + apiKeyInput.value = ""; + isValidated = false; + clearValidationMessage(); + } + } + + // Handle provider change - preserve API key value, just reset validation + providerSelect.addEventListener("change", () => { + isValidated = false; + clearValidationMessage(); + updateUI(); + }); + + updateUI(); // Initialize on load + + // Export validate function for form submission + window.validatePosterRatingApiKey = validateApiKey; +} + // Password Toggles function initializePasswordToggles() { document.querySelectorAll('.toggle-btn').forEach(btn => { diff --git a/app/templates/components/section_config.html b/app/templates/components/section_config.html index 0d5919c..aa0e7d5 100644 --- a/app/templates/components/section_config.html +++ b/app/templates/components/section_config.html @@ -51,24 +51,72 @@

Preferences

- +
- + + +
-
- - - + +
+ +
-
-

Enable ratings on posters via RatingPosterDB.

+ + + + + +

Smart Recommendations

- AI-powered suggestions based on your watch history + AI-powered suggestions based on your watch history, library and your reactions.

@@ -71,7 +71,7 @@

Smart Recommendations

Custom Catalogs

- Organize with customizable names and order + Organize with customizable names and order.

@@ -119,9 +119,9 @@

Multi-Language

-

RPDB Integration

+

Ratings Posters Integration

- Enhanced posters with ratings + Enhanced posters with ratings from RPDB or Top Posters.

@@ -136,9 +136,9 @@

RPDB Integration

-

Based on Your Loves

+

Based on Your own Taste Profile

- Recommendations from content you loved + Recommendations that are tailored to your own taste profile.