From 6d5310a793e40fceefb3cb87b02321a4c9124cd6 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Fri, 10 Oct 2025 22:06:07 -0500 Subject: [PATCH] gfd --- apps/media_library/api_views.py | 22 +++- apps/media_library/metadata.py | 52 ++++++++++ core/api_views.py | 102 +++++++++++++++++-- frontend/public/tmdb-logo-blue.svg | 1 + frontend/src/api.js | 13 +++ frontend/src/pages/Settings.jsx | 156 +++++++++++++++++++++++++---- 6 files changed, 312 insertions(+), 34 deletions(-) create mode 100644 frontend/public/tmdb-logo-blue.svg diff --git a/apps/media_library/api_views.py b/apps/media_library/api_views.py index 8b35e1ee..c54d4122 100644 --- a/apps/media_library/api_views.py +++ b/apps/media_library/api_views.py @@ -15,7 +15,11 @@ from rest_framework.exceptions import NotFound, ValidationError from apps.accounts.permissions import Authenticated from apps.media_library import models, serializers -from apps.media_library.metadata import sync_metadata +from apps.media_library.metadata import ( + get_tmdb_api_key, + sync_metadata, + validate_tmdb_api_key, +) from apps.media_library.tasks import ( enqueue_library_scan, sync_metadata_task, @@ -110,6 +114,22 @@ class LibraryViewSet(viewsets.ModelViewSet): ) def perform_create(self, serializer): + tmdb_key = (get_tmdb_api_key() or "").strip() + if not tmdb_key: + raise ValidationError( + { + "detail": "Add a valid TMDB API key before creating libraries. Metadata lookups require it." + } + ) + is_valid, message = validate_tmdb_api_key(tmdb_key) + if not is_valid: + raise ValidationError( + { + "detail": message + or "TMDB API key failed validation. Save a working key before creating libraries." + } + ) + library = serializer.save() if library.auto_scan_enabled: enqueue_library_scan(library_id=library.id, user_id=self.request.user.id) diff --git a/apps/media_library/metadata.py b/apps/media_library/metadata.py index 4f1231de..c546874e 100644 --- a/apps/media_library/metadata.py +++ b/apps/media_library/metadata.py @@ -16,6 +16,8 @@ logger = logging.getLogger(__name__) TMDB_API_KEY_SETTING = "tmdb-api-key" TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original" METADATA_CACHE_TIMEOUT = 60 * 60 * 6 # 6 hours +TMDB_VALIDATION_CACHE_SUCCESS_TTL = 60 * 30 # 30 minutes +TMDB_VALIDATION_CACHE_FAILURE_TTL = 60 * 5 # 5 minutes _REQUESTS_SESSION = requests.Session() _REQUESTS_SESSION.mount( "https://", @@ -24,6 +26,56 @@ _REQUESTS_SESSION.mount( tmdb.REQUESTS_SESSION = _REQUESTS_SESSION +def validate_tmdb_api_key(api_key: str, *, use_cache: bool = True) -> tuple[bool, str | None]: + """ + Validate a TMDB API key by calling the configuration endpoint. + + Returns a tuple of (is_valid, message). On success, message is None. + """ + normalized_key = (api_key or "").strip() + if not normalized_key: + return False, "TMDB API key is required." + + cache_key = f"tmdb-key-validation:{normalized_key}" + if use_cache: + cached = cache.get(cache_key) + if cached is not None: + return cached.get("valid", False), cached.get("message") + + try: + response = _REQUESTS_SESSION.get( + "https://api.themoviedb.org/3/configuration", + params={"api_key": normalized_key}, + timeout=5, + ) + except requests.RequestException as exc: + logger.warning("Unable to validate TMDB API key due to network error: %s", exc) + message = "Could not reach TMDB to validate the API key." + return False, message + + if response.status_code == 200: + if use_cache: + cache.set( + cache_key, + {"valid": True, "message": None}, + TMDB_VALIDATION_CACHE_SUCCESS_TTL, + ) + return True, None + + if response.status_code == 401: + message = "TMDB rejected the API key (HTTP 401 Unauthorized)." + else: + message = f"TMDB returned status {response.status_code} while validating the API key." + + if use_cache: + cache.set( + cache_key, + {"valid": False, "message": message}, + TMDB_VALIDATION_CACHE_FAILURE_TTL, + ) + return False, message + + def get_tmdb_api_key() -> Optional[str]: # Prefer CoreSettings, fallback to environment variable try: diff --git a/core/api_views.py b/core/api_views.py index 9de5aa5a..cbb3871e 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -4,6 +4,7 @@ import json import ipaddress import logging from rest_framework import viewsets, status +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated @@ -32,6 +33,10 @@ from apps.accounts.permissions import ( Authenticated, ) from dispatcharr.utils import get_client_ip +from apps.media_library.metadata import ( + TMDB_API_KEY_SETTING, + validate_tmdb_api_key, +) logger = logging.getLogger(__name__) @@ -64,25 +69,61 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): queryset = CoreSettings.objects.all() serializer_class = CoreSettingsSerializer + def _sanitize_tmdb_key(self, value: str | None) -> str: + candidate = value if isinstance(value, str) else ("" if value is None else str(value)) + trimmed = candidate.strip() + if not trimmed: + return trimmed + + is_valid, message = validate_tmdb_api_key(trimmed) + if not is_valid: + raise ValidationError({"detail": message or "TMDB API key is invalid."}) + return trimmed + def update(self, request, *args, **kwargs): + partial = kwargs.pop("partial", False) instance = self.get_object() - response = super().update(request, *args, **kwargs) - if instance.key == STREAM_HASH_KEY: - if instance.value != request.data["value"]: - rehash_streams.delay(request.data["value"].split(",")) + setting_key = instance.key + previous_value = instance.value + + data = request.data.copy() if hasattr(request.data, "copy") else dict(request.data) + if setting_key == TMDB_API_KEY_SETTING: + data["value"] = self._sanitize_tmdb_key(data.get("value")) + + serializer = self.get_serializer(instance, data=data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + updated_instance = serializer.instance + updated_value = updated_instance.value + + if getattr(updated_instance, "_prefetched_objects_cache", None): + updated_instance._prefetched_objects_cache = {} + + response = Response(serializer.data) + + if setting_key == STREAM_HASH_KEY and previous_value != updated_value: + rehash_streams.delay(str(updated_value).split(",")) # If DVR pre/post offsets changed, reschedule upcoming recordings try: from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY - if instance.key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY): - if instance.value != request.data.get("value"): + + if setting_key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY): + if previous_value != updated_value: try: # Prefer async task if Celery is available - from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change + from apps.channels.tasks import ( + reschedule_upcoming_recordings_for_offset_change, + ) + reschedule_upcoming_recordings_for_offset_change.delay() except Exception: # Fallback to synchronous implementation - from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl + from apps.channels.tasks import ( + reschedule_upcoming_recordings_for_offset_change_impl, + ) + reschedule_upcoming_recordings_for_offset_change_impl() except Exception: pass @@ -90,21 +131,60 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): return response def create(self, request, *args, **kwargs): - response = super().create(request, *args, **kwargs) + data = request.data.copy() if hasattr(request.data, "copy") else dict(request.data) + if data.get("key") == TMDB_API_KEY_SETTING: + data["value"] = self._sanitize_tmdb_key(data.get("value")) + + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + instance = serializer.instance + response = Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + # If creating DVR pre/post offset settings, also reschedule upcoming recordings try: - key = request.data.get("key") from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY - if key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY): + + if instance.key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY): try: from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change + reschedule_upcoming_recordings_for_offset_change.delay() except Exception: from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl + reschedule_upcoming_recordings_for_offset_change_impl() except Exception: pass return response + + @action(detail=False, methods=["post"], url_path="validate-tmdb") + def validate_tmdb(self, request, *args, **kwargs): + api_key = request.data.get("api_key") or request.data.get("value") + normalized = (api_key or "").strip() + + if not normalized: + return Response( + { + "valid": False, + "message": "TMDB API key is required to enable metadata lookups.", + }, + status=status.HTTP_200_OK, + ) + + is_valid, message = validate_tmdb_api_key(normalized) + if is_valid: + return Response({"valid": True}, status=status.HTTP_200_OK) + + return Response( + { + "valid": False, + "message": message or "TMDB API key is invalid.", + }, + status=status.HTTP_200_OK, + ) + @action(detail=False, methods=["post"], url_path="check") def check(self, request, *args, **kwargs): data = request.data diff --git a/frontend/public/tmdb-logo-blue.svg b/frontend/public/tmdb-logo-blue.svg new file mode 100644 index 00000000..4b21ded3 --- /dev/null +++ b/frontend/public/tmdb-logo-blue.svg @@ -0,0 +1 @@ +Asset 3 \ No newline at end of file diff --git a/frontend/src/api.js b/frontend/src/api.js index 97a6aa39..d050e4a7 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1428,6 +1428,19 @@ export default class API { } } + static async validateTmdbApiKey(apiKey) { + try { + const response = await request(`${host}/api/core/settings/validate-tmdb/`, { + method: 'POST', + body: { api_key: apiKey }, + }); + return response; + } catch (e) { + errorNotification('Failed to validate TMDB API key', e); + throw e; + } + } + static async updateSetting(values) { const { id, ...payload } = values; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index cf6675be..c502c75c 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -222,7 +222,15 @@ const SettingsPage = () => { const [editingLibrarySettings, setEditingLibrarySettings] = useState(null); const [librarySubmitting, setLibrarySubmitting] = useState(false); const tmdbSetting = settings['tmdb-api-key']; + const TMDB_REQUIREMENT_MESSAGE = + 'You need a TMDB API key for metadata and artwork. Without one, creating new libraries is disabled while we explore keyless options.'; const [tmdbKey, setTmdbKey] = useState(''); + const [tmdbKeyValid, setTmdbKeyValid] = useState(false); + const [tmdbValidating, setTmdbValidating] = useState(false); + const [tmdbValidationState, setTmdbValidationState] = useState('info'); + const [tmdbValidationMessage, setTmdbValidationMessage] = useState( + TMDB_REQUIREMENT_MESSAGE + ); const [savingTmdbKey, setSavingTmdbKey] = useState(false); const [tmdbHintOpen, setTmdbHintOpen] = useState(false); // Store pending changed settings when showing the dialog @@ -432,12 +440,6 @@ const SettingsPage = () => { } }, [authUser?.user_level, fetchMediaLibraries]); - useEffect(() => { - if (tmdbSetting && tmdbSetting.value !== undefined) { - setTmdbKey(tmdbSetting.value); - } - }, [tmdbSetting]); - const onSubmit = async () => { const values = form.getValues(); const changedSettings = {}; @@ -512,7 +514,7 @@ const SettingsPage = () => { console.error('Failed to save library', error); notifications.show({ title: 'Library error', - message: 'Unable to save library changes.', + message: error?.body?.detail || 'Unable to save library changes.', color: 'red', }); } finally { @@ -558,25 +560,89 @@ const SettingsPage = () => { } }; + const validateTmdbKeyValue = useCallback( + async (value) => { + const trimmed = (value || '').trim(); + + if (!trimmed) { + setTmdbValidating(false); + setTmdbKeyValid(false); + setTmdbValidationState('info'); + setTmdbValidationMessage(TMDB_REQUIREMENT_MESSAGE); + return { valid: false, message: 'TMDB API key is required.' }; + } + + setTmdbValidating(true); + setTmdbValidationState('info'); + setTmdbValidationMessage('Validating TMDB API key…'); + try { + const result = await API.validateTmdbApiKey(trimmed); + const isValid = Boolean(result?.valid); + setTmdbKeyValid(isValid); + setTmdbValidationState(isValid ? 'valid' : 'invalid'); + setTmdbValidationMessage( + isValid + ? 'TMDB key verified successfully. Metadata and artwork will load for your libraries.' + : result?.message || 'TMDB rejected the API key.' + ); + return result ?? { valid: isValid }; + } catch (error) { + setTmdbKeyValid(false); + setTmdbValidationState('error'); + setTmdbValidationMessage('Unable to reach TMDB to validate the API key right now.'); + throw error; + } finally { + setTmdbValidating(false); + } + }, + [TMDB_REQUIREMENT_MESSAGE] + ); + + useEffect(() => { + const currentValue = + tmdbSetting && tmdbSetting.value !== undefined ? tmdbSetting.value : ''; + setTmdbKey(currentValue); + validateTmdbKeyValue(currentValue).catch((error) => { + console.error('Failed to validate TMDB API key', error); + }); + }, [tmdbSetting?.value, validateTmdbKeyValue]); + const handleSaveTmdbKey = async () => { + const trimmedKey = tmdbKey.trim(); setSavingTmdbKey(true); try { + if (trimmedKey) { + const validation = await validateTmdbKeyValue(trimmedKey); + if (!validation?.valid) { + notifications.show({ + title: 'Invalid TMDB key', + message: validation?.message || 'TMDB rejected the API key.', + color: 'red', + }); + return; + } + } else { + await validateTmdbKeyValue(''); + } + if (tmdbSetting && tmdbSetting.id) { await API.updateSetting({ ...tmdbSetting, - value: tmdbKey, + value: trimmedKey, }); } else { await API.createSetting({ key: 'tmdb-api-key', name: 'TMDB API Key', - value: tmdbKey, + value: trimmedKey, }); } notifications.show({ - title: 'Saved', - message: 'TMDB API key updated.', - color: 'green', + title: trimmedKey ? 'TMDB key saved' : 'TMDB key cleared', + message: trimmedKey + ? 'TMDB API key saved and verified.' + : 'TMDB key removed. Add one to enable new libraries.', + color: trimmedKey ? 'green' : 'yellow', }); } catch (error) { console.error('Failed to save TMDB key', error); @@ -590,6 +656,15 @@ const SettingsPage = () => { } }; + const libraryActionsDisabled = tmdbValidating || !tmdbKeyValid; + const tmdbMessageColor = + tmdbValidationState === 'valid' + ? 'teal.6' + : tmdbValidationState === 'error' || tmdbValidationState === 'invalid' + ? 'red.6' + : 'orange.6'; + const addLibraryTooltipLabel = 'Save a valid TMDB API key before creating libraries.'; + const onNetworkAccessSubmit = async () => { setNetworkAccessSaved(false); setNetworkAccessError(null); @@ -855,16 +930,25 @@ const SettingsPage = () => { Configure local media libraries used for scanning and playback. - + + + + @@ -888,13 +972,28 @@ const SettingsPage = () => { size="xs" variant="light" onClick={handleSaveTmdbKey} - loading={savingTmdbKey} + loading={savingTmdbKey || tmdbValidating} > Save Metadata Settings + {tmdbValidationMessage && ( + + {tmdbValidating && } + + {tmdbValidationMessage} + + + )} + {!tmdbKeyValid && !tmdbValidating && ( + + A valid TMDB API key is required before you can add new libraries. We're + exploring future options that won't need a TMDB key. + + )} + {librariesLoading ? ( @@ -970,6 +1069,19 @@ const SettingsPage = () => { ))} )} +
+ + Powered by TMDB + +