mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
gfd
This commit is contained in:
parent
f100b5b0c3
commit
6d5310a793
6 changed files with 312 additions and 34 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
frontend/public/tmdb-logo-blue.svg
Normal file
1
frontend/public/tmdb-logo-blue.svg
Normal 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 |
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue