Merge pull request #347 from Nezreka/feat/service-status-indicators

Add per-service config status indicators to Settings Connections
pull/349/head
BoulderBadgeDad 2 months ago committed by GitHub
commit 9d1009fa69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -4384,6 +4384,70 @@ def _find_downloaded_file(download_path, track_data):
# --- Refactored Logic from GUI Threads ---
# This logic is extracted from the database update worker to be used directly by Flask.
# ── Settings Connection Status Registry ──
# Maps each service shown in Settings → Connections to its config requirements.
# Used by _is_service_configured() to drive the green/yellow header gradient.
#
# Registry entry shapes:
# {'required': [keys]} — green if all keys populated in config_manager.get(service)
# {'always': True} — always green (no credentials required, e.g. default-storefront iTunes)
# {'custom': callable} — callable(service_name) -> bool, for services with non-field checks (e.g. token file)
# {'any_of': [[keys_a], [keys_b]]} — green if any one group's keys are all populated (e.g. Qobuz: email+password OR token)
SERVICE_CONFIG_REGISTRY = {
'spotify': {'required': ['client_id', 'client_secret']},
'itunes': {'always': True}, # default storefront works anon
'deezer': {'always': True}, # anon search works, premium ARL is optional
'discogs': {'required': ['token']},
'tidal': {'custom': lambda _svc: _tidal_has_auth_token()},
'qobuz': {'any_of': [['email', 'password'], ['token'], ['user_auth_token']]},
'lastfm': {'required': ['api_key']},
'genius': {'required': ['access_token']},
'acoustid': {'required': ['api_key']},
'listenbrainz': {'required': ['token']},
'hydrabase': {'required': ['url', 'api_key']},
}
def _tidal_has_auth_token() -> bool:
"""Check if Tidal has a cached OAuth token. Tidal uses a token file, not config fields."""
try:
return bool(tidal_client and tidal_client.is_authenticated())
except Exception:
return False
def _is_service_configured(service: str) -> bool:
"""Return True if the user has provided the required credentials for this service.
Drives the green/yellow header gradient on the Connections tab.
"""
entry = SERVICE_CONFIG_REGISTRY.get(service)
if not entry:
return False
if entry.get('always'):
return True
if 'custom' in entry:
try:
return bool(entry['custom'](service))
except Exception:
return False
service_config = config_manager.get(service, {}) or {}
if 'required' in entry:
return all(bool(service_config.get(key)) for key in entry['required'])
if 'any_of' in entry:
for key_group in entry['any_of']:
if all(bool(service_config.get(key)) for key in key_group):
return True
return False
return False
def run_service_test(service, test_config):
"""
Performs the actual connection test for a given service.
@ -4653,6 +4717,65 @@ def run_service_test(service, test_config):
return False, f"Lidarr returned HTTP {resp.status_code}"
except Exception as e:
return False, f"Lidarr connection error: {str(e)}"
elif service == "itunes":
# Public API — just confirm we can reach it with a cheap search
try:
storefront = config_manager.get('itunes.storefront', 'US') or 'US'
resp = requests.get(
'https://itunes.apple.com/search',
params={'term': 'beatles', 'limit': 1, 'country': storefront, 'media': 'music'},
timeout=5,
)
if resp.ok and resp.json().get('resultCount', 0) >= 0:
return True, f"iTunes Search API reachable (storefront: {storefront})"
return False, f"iTunes returned HTTP {resp.status_code}"
except Exception as e:
return False, f"iTunes connection error: {str(e)}"
elif service == "deezer":
# Public API — anon search works without credentials
try:
resp = requests.get(
'https://api.deezer.com/search/artist',
params={'q': 'beatles', 'limit': 1},
timeout=5,
)
if resp.ok and isinstance(resp.json(), dict):
return True, "Deezer Public API reachable"
return False, f"Deezer returned HTTP {resp.status_code}"
except Exception as e:
return False, f"Deezer connection error: {str(e)}"
elif service == "discogs":
token = test_config.get('token', '') or config_manager.get('discogs.token', '')
if not token:
return False, "Missing Discogs personal token."
try:
resp = requests.get(
'https://api.discogs.com/database/search',
params={'q': 'beatles', 'per_page': 1},
headers={'Authorization': f'Discogs token={token}', 'User-Agent': 'SoulSync/1.0'},
timeout=10,
)
if resp.ok:
return True, "Discogs API reachable with provided token"
if resp.status_code == 401:
return False, "Discogs token rejected (HTTP 401)"
return False, f"Discogs returned HTTP {resp.status_code}"
except Exception as e:
return False, f"Discogs connection error: {str(e)}"
elif service == "qobuz":
try:
if qobuz_enrichment_worker and qobuz_enrichment_worker.client and qobuz_enrichment_worker.client.is_authenticated():
return True, "Qobuz client authenticated"
return False, "Qobuz not authenticated. Provide email/password or user auth token."
except Exception as e:
return False, f"Qobuz connection error: {str(e)}"
elif service == "hydrabase":
try:
if hydrabase_client and hydrabase_client.is_connected():
return True, "Hydrabase connected"
return False, "Hydrabase not connected. Configure URL + API key and click Connect."
except Exception as e:
return False, f"Hydrabase connection error: {str(e)}"
return False, "Unknown service."
except AttributeError as e:
# This specifically catches the error you reported for Jellyfin
@ -7209,6 +7332,120 @@ def test_connection_endpoint():
return jsonify({"success": success, "error": "" if success else message, "message": message if success else ""})
@app.route('/api/settings/config-status', methods=['GET'])
def settings_config_status_endpoint():
"""Return per-service config state for the Settings → Connections page.
Drives the green/yellow header gradient. No API calls just config reads.
"""
try:
return jsonify({
service: {'configured': _is_service_configured(service)}
for service in SERVICE_CONFIG_REGISTRY
})
except Exception as e:
logger.error(f"config-status error: {e}")
return jsonify({"error": str(e)}), 500
# ── Per-service verify cache ──
# Stores the last verify result per service for 5 minutes to prevent
# hammering external APIs when the user rapidly expands/collapses cards.
_settings_verify_cache = {} # service -> {'success': bool, 'message': str, 'error': str, 'ts': float}
_settings_verify_cache_lock = threading.Lock()
_SETTINGS_VERIFY_TTL_SECONDS = 300
def _get_cached_verify_result(service: str):
with _settings_verify_cache_lock:
entry = _settings_verify_cache.get(service)
if entry and (time.time() - entry['ts']) < _SETTINGS_VERIFY_TTL_SECONDS:
return entry
return None
def _store_verify_result(service: str, success: bool, message: str):
with _settings_verify_cache_lock:
_settings_verify_cache[service] = {
'success': bool(success),
'message': message or '',
'error': '' if success else (message or 'Unknown error'),
'ts': time.time(),
}
def _run_single_verify(service: str):
"""Run verify for one service, reading its current saved config. Returns cached
result if recent, else executes run_service_test and caches the outcome.
"""
if service not in SERVICE_CONFIG_REGISTRY:
return {'success': False, 'error': f'Unknown service: {service}', 'cached': False}
cached = _get_cached_verify_result(service)
if cached:
return {
'success': cached['success'],
'error': cached.get('error', ''),
'message': cached.get('message', ''),
'cached': True,
}
try:
saved_config = config_manager.get(service, {}) or {}
success, message = run_service_test(service, saved_config)
_store_verify_result(service, success, message)
return {
'success': bool(success),
'error': '' if success else (message or 'Verification failed'),
'message': message or '',
'cached': False,
}
except Exception as e:
logger.error(f"verify error for {service}: {e}")
_store_verify_result(service, False, str(e))
return {'success': False, 'error': str(e), 'cached': False}
@app.route('/api/settings/verify', methods=['POST'])
def settings_verify_endpoint():
"""Run connection verification for one or more services.
Body: {"services": ["spotify", "deezer"]} which services to check
Query: ?force=true bust cache and re-run
Returns {service: {success, error, message, cached}} per requested service.
Concurrency capped at 3 to avoid rate-limiting ourselves on Expand All.
"""
try:
data = request.get_json(silent=True) or {}
services = data.get('services') or []
if isinstance(services, str):
services = [services]
if not services:
return jsonify({'error': 'No services specified'}), 400
force = (request.args.get('force') or '').strip().lower() in ('1', 'true', 'yes')
if force:
with _settings_verify_cache_lock:
for svc in services:
_settings_verify_cache.pop(svc, None)
from concurrent.futures import ThreadPoolExecutor, as_completed
results = {}
with ThreadPoolExecutor(max_workers=3) as pool:
futures = {pool.submit(_run_single_verify, svc): svc for svc in services}
for fut in as_completed(futures):
svc = futures[fut]
try:
results[svc] = fut.result()
except Exception as e:
results[svc] = {'success': False, 'error': str(e), 'cached': False}
return jsonify(results)
except Exception as e:
logger.error(f"settings/verify error: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/test-dashboard-connection', methods=['POST'])
def test_dashboard_connection_endpoint():
"""Test connection from dashboard - creates specific dashboard activity items"""

@ -3871,7 +3871,7 @@
</div>
<!-- Spotify Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="spotify">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #1DB954;"></span>
<h4 class="service-title spotify-title">Spotify</h4>
@ -3914,7 +3914,7 @@
</div>
<!-- iTunes Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="itunes">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #fc3c44;"></span>
<h4 class="service-title itunes-title">iTunes / Apple Music</h4>
@ -3949,7 +3949,7 @@
</div>
<!-- Deezer OAuth Auth -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="deezer">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #A238FF;"></span>
<h4 class="service-title deezer-title">Deezer (Favorites & Playlists)</h4>
@ -3995,7 +3995,7 @@
</div>
<!-- Discogs Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="discogs">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #e0d4b8;"></span>
<h4 class="service-title">Discogs</h4>
@ -4017,7 +4017,7 @@
</div>
<!-- Tidal Playlist/Metadata Auth -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="tidal">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #ff6600;"></span>
<h4 class="service-title tidal-title">Tidal (Playlists & Metadata)</h4>
@ -4052,7 +4052,7 @@
</div>
<!-- Qobuz Metadata/Enrichment Auth -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="qobuz">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #4285f4;"></span>
<h4 class="service-title qobuz-title">Qobuz (Metadata & Enrichment)</h4>
@ -4109,7 +4109,7 @@
</div>
<!-- Last.fm Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="lastfm">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #d51007;"></span>
<h4 class="service-title lastfm-title">Last.fm</h4>
@ -4146,7 +4146,7 @@
</div>
<!-- Genius Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="genius">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #ffff64;"></span>
<h4 class="service-title genius-title">Genius</h4>
@ -4168,7 +4168,7 @@
</div>
<!-- AcoustID Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="acoustid">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #ba55d3;"></span>
<h4 class="service-title acoustid-title">AcoustID Verification</h4>
@ -4205,7 +4205,7 @@
</div>
<!-- ListenBrainz Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="listenbrainz">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #eb743b;"></span>
<h4 class="service-title listenbrainz-title">ListenBrainz</h4>
@ -4238,7 +4238,7 @@
</div>
<!-- Hydrabase P2P Metadata -->
<div class="api-service-frame stg-service" data-stg="connections">
<div class="api-service-frame stg-service" data-stg="connections" data-service="hydrabase">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #00b4d8;"></span>
<h4 class="service-title">Hydrabase</h4>

@ -5808,17 +5808,195 @@ function switchSettingsTab(tab) {
} else {
_logViewerStop();
}
// Refresh the green/yellow header gradient when arriving on Connections
if (tab === 'connections') {
try { applyServiceStatusGradients(); } catch (e) { }
}
}
// ── Settings → Connections: per-service status gradient + verify wiring ──
// Gradient shows green when the user has filled in credentials, yellow when empty.
// It's based purely on config presence (cheap, no API calls). The verify layer —
// which runs on expand / Expand All — surfaces whether those credentials actually
// work, via an inline warning bar inside the expanded panel.
let _stgServiceStatusState = {}; // service -> {configured: bool}
let _stgServiceVerifyInFlight = {}; // service -> true while a verify call is running
async function applyServiceStatusGradients() {
try {
const resp = await fetch('/api/settings/config-status');
if (!resp.ok) return;
const data = await resp.json();
_stgServiceStatusState = data || {};
document.querySelectorAll('#settings-page .stg-service[data-service]').forEach(card => {
const service = card.getAttribute('data-service');
const header = card.querySelector('.stg-service-header');
if (!service || !header) return;
const configured = !!(data[service] && data[service].configured);
header.classList.toggle('status-configured', configured);
header.classList.toggle('status-missing', !configured);
// Ensure the header has a spinner placeholder for the verify-checking state
if (!header.querySelector('.stg-service-verify-spinner')) {
const spinner = document.createElement('span');
spinner.className = 'stg-service-verify-spinner';
// Insert before the chevron on the right
const chevron = header.querySelector('.stg-service-chevron');
if (chevron) header.insertBefore(spinner, chevron);
else header.appendChild(spinner);
}
});
} catch (e) {
console.warn('[Settings Status] Failed to apply gradients:', e);
}
}
function _stgSetCheckingState(service, isChecking) {
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
if (!card) return;
const header = card.querySelector('.stg-service-header');
const body = card.querySelector('.stg-service-body');
if (header) {
header.classList.toggle('status-checking', !!isChecking);
// Lazy-create the spinner element so it's there even if
// applyServiceStatusGradients() hasn't run yet.
if (!header.querySelector('.stg-service-verify-spinner')) {
const spinner = document.createElement('span');
spinner.className = 'stg-service-verify-spinner';
const chevron = header.querySelector('.stg-service-chevron');
if (chevron) header.insertBefore(spinner, chevron);
else header.appendChild(spinner);
}
}
if (!body) return;
const existing = body.querySelector('.stg-service-verify-status');
if (isChecking) {
if (!existing) {
const status = document.createElement('div');
status.className = 'stg-service-verify-status';
status.textContent = 'Testing connection…';
body.insertBefore(status, body.firstChild);
}
} else if (existing) {
existing.remove();
}
}
function _stgShowVerifyWarning(service, message) {
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
if (!card) return;
const body = card.querySelector('.stg-service-body');
if (!body) return;
const existing = body.querySelector('.stg-service-warning');
if (existing) existing.remove();
const warning = document.createElement('div');
warning.className = 'stg-service-warning';
warning.innerHTML = `
<span class="stg-service-warning-icon">&#9888;</span>
<span class="stg-service-warning-text"></span>
`;
warning.querySelector('.stg-service-warning-text').textContent =
message || 'Connection test failed.';
body.insertBefore(warning, body.firstChild);
}
function _stgClearVerifyWarning(service) {
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
if (!card) return;
const existing = card.querySelector('.stg-service-warning');
if (existing) existing.remove();
}
async function _stgRefreshAfterSave() {
// Called after a successful settings save. Cheap gradient refresh always,
// plus re-verify any cards the user currently has expanded (so they see
// immediate feedback on credentials they just edited). Collapsed cards
// keep their cached verify result until the user expands them.
try {
await applyServiceStatusGradients();
const expandedServices = Array.from(
document.querySelectorAll('#settings-page .stg-service.expanded[data-service]')
)
.map(card => card.getAttribute('data-service'))
.filter(Boolean);
if (expandedServices.length > 0) {
_stgVerifyServices(expandedServices, { force: true });
}
} catch (e) {
console.warn('[Settings Status] Post-save refresh failed:', e);
}
}
async function _stgVerifyServices(services, { force = false } = {}) {
if (!services || !services.length) return {};
// Mark all as checking immediately so the user sees spinners/status lines
services.forEach(svc => {
_stgServiceVerifyInFlight[svc] = true;
_stgSetCheckingState(svc, true);
_stgClearVerifyWarning(svc);
});
try {
const url = '/api/settings/verify' + (force ? '?force=true' : '');
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ services })
});
const data = await resp.json();
services.forEach(svc => {
_stgServiceVerifyInFlight[svc] = false;
_stgSetCheckingState(svc, false);
const result = data[svc];
if (result && result.success === false) {
_stgShowVerifyWarning(svc, result.error || result.message || '');
}
});
return data;
} catch (e) {
console.warn('[Settings Verify] Network error:', e);
services.forEach(svc => {
_stgServiceVerifyInFlight[svc] = false;
_stgSetCheckingState(svc, false);
_stgShowVerifyWarning(svc, 'Unable to reach the verification endpoint.');
});
return {};
}
}
function toggleStgService(el) {
const service = el.closest('.stg-service');
if (service) service.classList.toggle('expanded');
if (service) {
const wasExpanded = service.classList.contains('expanded');
service.classList.toggle('expanded');
// Fire verify when expanding a single card (not on collapse). The backend
// caches per service for 5 min, so rapid expand/collapse won't re-ping.
if (!wasExpanded) {
const serviceName = service.getAttribute('data-service');
if (serviceName && !_stgServiceVerifyInFlight[serviceName]) {
_stgVerifyServices([serviceName]);
}
}
}
}
function toggleAllServiceAccordions(btn) {
const services = document.querySelectorAll('#settings-page .stg-service');
const allExpanded = Array.from(services).every(s => s.classList.contains('expanded'));
services.forEach(s => s.classList.toggle('expanded', !allExpanded));
const willExpand = !allExpanded;
services.forEach(s => s.classList.toggle('expanded', willExpand));
btn.textContent = allExpanded ? 'Expand All' : 'Collapse All';
// On Expand All, fire a single batched verify for every service that has a
// data-service attribute. Backend caps concurrency at 3 to avoid rate limits.
// Skipped on Collapse All.
if (willExpand) {
const serviceNames = Array.from(services)
.map(s => s.getAttribute('data-service'))
.filter(Boolean)
.filter(name => !_stgServiceVerifyInFlight[name]);
if (serviceNames.length > 0) {
_stgVerifyServices(serviceNames);
}
}
}
// ── Hybrid source priority list (drag-and-drop) ──
@ -7773,12 +7951,15 @@ async function saveSettings(quiet = false) {
if (result.success && qualityProfileSaved && lookbackSaved) {
showToast(quiet ? 'Settings auto-saved' : 'Settings saved successfully', 'success');
_forceServiceStatusRefresh();
_stgRefreshAfterSave();
} else if (result.success && qualityProfileSaved && !lookbackSaved) {
showToast('Settings saved, but discovery lookback period failed to save', 'warning');
_forceServiceStatusRefresh();
_stgRefreshAfterSave();
} else if (result.success && !qualityProfileSaved) {
showToast('Settings saved, but quality profile failed to save', 'warning');
_forceServiceStatusRefresh();
_stgRefreshAfterSave();
} else {
showToast(`Failed to save settings: ${result.error}`, 'error', 'set-services');
}

@ -52349,23 +52349,25 @@ tr.tag-diff-same {
color: rgba(255, 255, 255, 0.95);
}
/* Brand color dot */
/* Brand color dot — always glows, glow intensifies on hover/expand */
#settings-page .stg-service-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: currentColor;
opacity: 0.45;
opacity: 0.85;
box-shadow: 0 0 6px currentColor;
transition: opacity 0.25s, transform 0.25s, box-shadow 0.25s;
}
#settings-page .stg-service:hover .stg-service-dot {
opacity: 0.75;
opacity: 1;
box-shadow: 0 0 9px currentColor;
}
#settings-page .stg-service.expanded .stg-service-dot {
opacity: 1;
transform: scale(1.25);
box-shadow: 0 0 8px currentColor;
box-shadow: 0 0 12px currentColor;
}
/* Chevron */
@ -52400,6 +52402,90 @@ tr.tag-diff-same {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
/* Service config-status gradients
Applied to the header based on whether the user has filled in credentials.
Subtle left-to-transparent fade that reads at a glance when scrolling. */
#settings-page .stg-service-header.status-configured {
background-image: linear-gradient(
90deg,
rgba(46, 204, 113, 0.32) 0%,
rgba(46, 204, 113, 0.12) 18%,
rgba(46, 204, 113, 0.04) 45%,
transparent 80%
);
}
#settings-page .stg-service-header.status-missing {
background-image: linear-gradient(
90deg,
rgba(241, 196, 15, 0.35) 0%,
rgba(241, 196, 15, 0.13) 18%,
rgba(241, 196, 15, 0.04) 45%,
transparent 80%
);
}
/* Keep hover subtlety — overlay a gentle highlight without clobbering the gradient */
#settings-page .stg-service-header.status-configured:hover,
#settings-page .stg-service-header.status-missing:hover {
background-color: rgba(255, 255, 255, 0.035);
}
/* Verify state: spinner badge in the header
Shown while a verify API call is in flight for this service. */
#settings-page .stg-service-verify-spinner {
display: none;
width: 12px;
height: 12px;
border: 2px solid rgba(255, 255, 255, 0.15);
border-top-color: rgba(255, 255, 255, 0.65);
border-radius: 50%;
animation: stgSpin 0.9s linear infinite;
flex-shrink: 0;
margin-left: 6px;
margin-right: 6px;
}
#settings-page .stg-service-header.status-checking .stg-service-verify-spinner {
display: inline-block;
}
@keyframes stgSpin {
to { transform: rotate(360deg); }
}
/* Verify state: status line inside panel body
"Testing connection…" while verify runs, removed when result arrives. */
#settings-page .stg-service-verify-status {
padding: 10px 18px;
font-size: 0.78em;
color: rgba(255, 255, 255, 0.45);
font-style: italic;
letter-spacing: 0.02em;
}
/* Verify failure warning bar at top of expanded panel
Shown when the verify call failed. Removed on next successful verify. */
#settings-page .stg-service-warning {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 10px 18px;
padding: 10px 12px;
background: rgba(231, 76, 60, 0.08);
border: 1px solid rgba(231, 76, 60, 0.25);
border-left: 3px solid rgba(231, 76, 60, 0.65);
border-radius: 6px;
font-size: 0.82em;
color: rgba(255, 220, 216, 0.9);
line-height: 1.4;
}
#settings-page .stg-service-warning .stg-service-warning-icon {
flex-shrink: 0;
font-size: 1.05em;
color: rgba(231, 76, 60, 0.85);
line-height: 1.3;
}
#settings-page .stg-service-warning .stg-service-warning-text {
flex: 1;
}
/* Expand All / Collapse All toggle button */
#settings-page .stg-accordion-toggle {
background: none;

Loading…
Cancel
Save