This commit is contained in:
Dispatcharr 2025-10-10 22:06:07 -05:00
parent f100b5b0c3
commit 6d5310a793
6 changed files with 312 additions and 34 deletions

View file

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

View file

@ -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:

View file

@ -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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 273.42 35.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="17.76" x2="273.42" y2="17.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 3</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M191.85,35.37h63.9A17.67,17.67,0,0,0,273.42,17.7h0A17.67,17.67,0,0,0,255.75,0h-63.9A17.67,17.67,0,0,0,174.18,17.7h0A17.67,17.67,0,0,0,191.85,35.37ZM10.1,35.42h7.8V6.92H28V0H0v6.9H10.1Zm28.1,0H46V8.25h.1L55.05,35.4h6L70.3,8.25h.1V35.4h7.8V0H66.45l-8.2,23.1h-.1L50,0H38.2ZM89.14.12h11.7a33.56,33.56,0,0,1,8.08,1,18.52,18.52,0,0,1,6.67,3.08,15.09,15.09,0,0,1,4.53,5.52,18.5,18.5,0,0,1,1.67,8.25,16.91,16.91,0,0,1-1.62,7.58,16.3,16.3,0,0,1-4.38,5.5,19.24,19.24,0,0,1-6.35,3.37,24.53,24.53,0,0,1-7.55,1.15H89.14Zm7.8,28.2h4a21.66,21.66,0,0,0,5-.55A10.58,10.58,0,0,0,110,26a8.73,8.73,0,0,0,2.68-3.35,11.9,11.9,0,0,0,1-5.08,9.87,9.87,0,0,0-1-4.52,9.17,9.17,0,0,0-2.63-3.18A11.61,11.61,0,0,0,106.22,8a17.06,17.06,0,0,0-4.68-.63h-4.6ZM133.09.12h13.2a32.87,32.87,0,0,1,4.63.33,12.66,12.66,0,0,1,4.17,1.3,7.94,7.94,0,0,1,3,2.72,8.34,8.34,0,0,1,1.15,4.65,7.48,7.48,0,0,1-1.67,5,9.13,9.13,0,0,1-4.43,2.82V17a10.28,10.28,0,0,1,3.18,1,8.51,8.51,0,0,1,2.45,1.85,7.79,7.79,0,0,1,1.57,2.62,9.16,9.16,0,0,1,.55,3.2,8.52,8.52,0,0,1-1.2,4.68,9.32,9.32,0,0,1-3.1,3A13.38,13.38,0,0,1,152.32,35a22.5,22.5,0,0,1-4.73.5h-14.5Zm7.8,14.15h5.65a7.65,7.65,0,0,0,1.78-.2,4.78,4.78,0,0,0,1.57-.65,3.43,3.43,0,0,0,1.13-1.2,3.63,3.63,0,0,0,.42-1.8A3.3,3.3,0,0,0,151,8.6a3.42,3.42,0,0,0-1.23-1.13A6.07,6.07,0,0,0,148,6.9a9.9,9.9,0,0,0-1.85-.18h-5.3Zm0,14.65h7a8.27,8.27,0,0,0,1.83-.2,4.67,4.67,0,0,0,1.67-.7,3.93,3.93,0,0,0,1.23-1.3,3.8,3.8,0,0,0,.47-1.95,3.16,3.16,0,0,0-.62-2,4,4,0,0,0-1.58-1.18,8.23,8.23,0,0,0-2-.55,15.12,15.12,0,0,0-2.05-.15h-5.9Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -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;

View file

@ -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 = () => {
<Text c="dimmed" size="sm">
Configure local media libraries used for scanning and playback.
</Text>
<Button
size="xs"
leftSection={<Plus size={14} />}
onClick={() => {
setEditingLibrarySettings(null);
setLibraryModalOpen(true);
}}
<Tooltip
label={addLibraryTooltipLabel}
disabled={!libraryActionsDisabled}
withArrow
>
Add Library
</Button>
<span>
<Button
size="xs"
leftSection={<Plus size={14} />}
disabled={libraryActionsDisabled}
onClick={() => {
setEditingLibrarySettings(null);
setLibraryModalOpen(true);
}}
>
Add Library
</Button>
</span>
</Tooltip>
</Group>
<Stack spacing="xs">
@ -888,13 +972,28 @@ const SettingsPage = () => {
size="xs"
variant="light"
onClick={handleSaveTmdbKey}
loading={savingTmdbKey}
loading={savingTmdbKey || tmdbValidating}
>
Save Metadata Settings
</Button>
</Group>
{tmdbValidationMessage && (
<Group gap="xs" align="center">
{tmdbValidating && <Loader size="xs" />}
<Text size="xs" c={tmdbMessageColor}>
{tmdbValidationMessage}
</Text>
</Group>
)}
</Stack>
{!tmdbKeyValid && !tmdbValidating && (
<Alert color="yellow" variant="light" radius="md">
A valid TMDB API key is required before you can add new libraries. We're
exploring future options that won&apos;t need a TMDB key.
</Alert>
)}
{librariesLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
@ -970,6 +1069,19 @@ const SettingsPage = () => {
))}
</Stack>
)}
<Center py="md">
<Anchor
href="https://www.themoviedb.org/"
target="_blank"
rel="noopener noreferrer"
>
<img
src="/tmdb-logo-blue.svg"
alt="Powered by TMDB"
style={{ maxWidth: 180 }}
/>
</Anchor>
</Center>
</Stack>
</Accordion.Panel>
</Accordion.Item>