feature/analog_input (#12)

Co-authored-by: Paul Obernesser <paul.obernesser@inncubator.at>
Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
2025-12-03 12:28:30 +01:00
parent 98dd00e653
commit 6c7b74a0b2
19 changed files with 1497 additions and 924 deletions

View File

@@ -8,6 +8,8 @@ import requests
from dotenv import load_dotenv
import streamlit as st
from auracast.utils.read_temp import read_case_temp, read_cpu_temp
from auracast import auracast_config
from auracast.utils.frontend_auth import (
is_pw_disabled,
@@ -100,6 +102,10 @@ except Exception:
# Define is_streaming early from the fetched status for use throughout the UI
is_streaming = bool(saved_settings.get("is_streaming", False))
# Extract secondary status, if provided by the backend /status endpoint.
secondary_status = saved_settings.get("secondary") or {}
secondary_is_streaming = bool(saved_settings.get("secondary_is_streaming", secondary_status.get("is_streaming", False)))
st.title("Auracast Audio Mode Control")
def render_stream_controls(status_streaming: bool, start_label: str, stop_label: str, mode_label: str):
@@ -119,9 +125,10 @@ def render_stream_controls(status_streaming: bool, start_label: str, stop_label:
# Audio mode selection with persisted default
# Note: backend persists 'USB' for any device:<name> source (including AES67). We default to 'USB' in that case.
options = [
"Demo",
"USB",
"Network",
"Demo",
"Analog",
"USB",
"Network",
]
saved_audio_mode = saved_settings.get("audio_mode", "Demo")
if saved_audio_mode not in options:
@@ -153,7 +160,12 @@ if isinstance(backend_mode_raw, str):
elif backend_mode_raw in options:
backend_mode_mapped = backend_mode_raw
running_mode = backend_mode_mapped if (is_streaming and backend_mode_mapped) else audio_mode
# When Analog is selected in the UI we always show it as such, even though the
# backend currently persists USB for all device sources.
if audio_mode == "Analog":
running_mode = "Analog"
else:
running_mode = backend_mode_mapped if (is_streaming and backend_mode_mapped) else audio_mode
is_started = False
is_stopped = False
@@ -338,111 +350,103 @@ if audio_mode == "Demo":
quality = None # Not used in demo mode
else:
# Stream quality selection (now enabled)
quality_options = list(QUALITY_MAP.keys())
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
quality = st.selectbox(
"Stream Quality (Sampling Rate)",
quality_options,
index=quality_options.index(default_quality),
help="Select the audio sampling rate for the stream. Lower rates may improve compatibility."
)
# --- Mode-specific configuration ---
default_name = saved_settings.get('channel_names', ["Broadcast0"])[0]
default_lang = saved_settings.get('languages', ["deu"])[0]
default_input = saved_settings.get('input_device') or 'default'
stream_name = st.text_input(
"Channel Name",
value=default_name,
help="The primary name for your broadcast. Like the SSID of a WLAN, it identifies your stream for receivers."
)
raw_program_info = saved_settings.get('program_info', default_name)
if isinstance(raw_program_info, list) and raw_program_info:
default_program_info = raw_program_info[0]
else:
default_program_info = raw_program_info
program_info = st.text_input(
"Program Info",
value=default_program_info,
help="Additional details about the broadcast program, such as its content or purpose. Shown to receivers for more context."
)
language = st.text_input(
"Language (ISO 639-3)",
value=default_lang,
help="Three-letter language code (e.g., 'eng' for English, 'deu' for German). Used by receivers to display the language of the stream. See: https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes"
)
# Optional broadcast code for coded streams
stream_passwort = st.text_input(
"Stream Passwort",
value="",
type="password",
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
)
# Flags and QoS row (compact, four columns)
col_flags1, col_flags2, col_pdelay, col_rtn = st.columns([1, 1, 0.7, 0.6], gap="small")
with col_flags1:
assisted_listening = st.checkbox(
"Assistive listening",
value=bool(saved_settings.get('assisted_listening_stream', False)),
help="tells the receiver that this is an assistive listening stream"
)
with col_flags2:
immediate_rendering = st.checkbox(
"Immediate rendering",
value=bool(saved_settings.get('immediate_rendering', False)),
help="tells the receiver to ignore presentation delay and render immediately if possible."
)
# QoS/presentation controls inline with flags
default_pdelay = int(saved_settings.get('presentation_delay_us', 40000) or 40000)
with col_pdelay:
default_pdelay_ms = max(10, min(200, default_pdelay // 1000))
presentation_delay_ms = st.number_input(
"Delay (ms)",
min_value=10, max_value=200, step=5, value=default_pdelay_ms,
help="Delay between capture and presentation for receivers."
)
default_rtn = int(saved_settings.get('rtn', 4) or 4)
with col_rtn:
rtn_options = [1,2,3,4]
default_rtn_clamped = min(4, max(1, default_rtn))
rtn = st.selectbox(
"RTN", options=rtn_options, index=rtn_options.index(default_rtn_clamped),
help="Number of ISO retransmissions (higher improves robustness at cost of airtime)."
)
default_lang = saved_settings.get('languages', ["deu"])[0]
# Input device selection for USB or AES67 mode
if audio_mode in ("USB", "Network"):
# Per-mode configuration and controls
input_device = None
radio2_enabled = False
radio1_cfg = None
radio2_cfg = None
if audio_mode == "Analog":
# --- Radio 1 controls ---
st.subheader("Radio 1")
quality_options = list(QUALITY_MAP.keys())
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
quality1 = st.selectbox(
"Stream Quality (Radio 1)",
quality_options,
index=quality_options.index(default_quality),
help="Select the audio sampling rate for Radio 1."
)
stream_passwort1 = st.text_input(
"Stream Passwort (Radio 1)",
value="",
type="password",
help="Optional: Set a broadcast code for Radio 1."
)
col_r1_flags1, col_r1_flags2, col_r1_pdelay, col_r1_rtn = st.columns([1, 1, 0.7, 0.6], gap="small")
with col_r1_flags1:
assisted_listening1 = st.checkbox(
"Assistive listening (R1)",
value=bool(saved_settings.get('assisted_listening_stream', False)),
help="tells the receiver that this is an assistive listening stream"
)
with col_r1_flags2:
immediate_rendering1 = st.checkbox(
"Immediate rendering (R1)",
value=bool(saved_settings.get('immediate_rendering', False)),
help="tells the receiver to ignore presentation delay and render immediately if possible."
)
default_pdelay = int(saved_settings.get('presentation_delay_us', 40000) or 40000)
with col_r1_pdelay:
default_pdelay_ms = max(10, min(200, default_pdelay // 1000))
presentation_delay_ms1 = st.number_input(
"Delay (ms, R1)",
min_value=10, max_value=200, step=5, value=default_pdelay_ms,
help="Delay between capture and presentation for Radio 1."
)
default_rtn = int(saved_settings.get('rtn', 4) or 4)
with col_r1_rtn:
rtn_options = [1,2,3,4]
default_rtn_clamped = min(4, max(1, default_rtn))
rtn1 = st.selectbox(
"RTN (R1)", options=rtn_options, index=rtn_options.index(default_rtn_clamped),
help="Number of ISO retransmissions for Radio 1."
)
col_r1_name, col_r1_lang = st.columns([2, 1])
with col_r1_name:
stream_name1 = st.text_input(
"Channel Name (Radio 1)",
value=default_name,
help="Name for the first analog radio (Radio 1)."
)
with col_r1_lang:
language1 = st.text_input(
"Language (ISO 639-3) (Radio 1)",
value=default_lang,
help="Language code for Radio 1."
)
program_info1 = st.text_input(
"Program Info (Radio 1)",
value=default_program_info,
help="Program information for Radio 1."
)
# Analog mode exposes only ALSA ch1/ch2 inputs.
if not is_streaming:
# Only query device lists when NOT streaming to avoid extra backend calls
try:
endpoint = "/audio_inputs_pw_usb" if audio_mode == "USB" else "/audio_inputs_pw_network"
resp = requests.get(f"{BACKEND_URL}{endpoint}")
resp = requests.get(f"{BACKEND_URL}/audio_inputs_pw_usb")
device_list = resp.json().get('inputs', [])
except Exception as e:
st.error(f"Failed to fetch devices: {e}")
device_list = []
# Display "name [id]" but use name as value
input_options = [f"{d['name']} [{d['id']}]" for d in device_list]
option_name_map = {f"{d['name']} [{d['id']}]": d['name'] for d in device_list}
device_names = [d['name'] for d in device_list]
analog_devices = [d for d in device_list if d.get('name') in ('ch1', 'ch2')]
# Determine default input by name (from persisted server state)
default_input_name = saved_settings.get('input_device')
if default_input_name not in device_names and device_names:
default_input_name = device_names[0]
default_input_label = None
for label, name in option_name_map.items():
if name == default_input_name:
default_input_label = label
break
if not input_options:
warn_text = (
"No USB audio input devices found. Connect a USB input and click Refresh."
if audio_mode == "USB" else
"No AES67/Network inputs found."
)
st.warning(warn_text)
if not analog_devices:
st.warning("No Analog (ch1/ch2) ALSA inputs found. Check asound configuration.")
if st.button("Refresh", disabled=is_streaming):
try:
r = requests.post(f"{BACKEND_URL}/refresh_audio_devices", timeout=8)
@@ -451,16 +455,246 @@ else:
except Exception as e:
st.error(f"Failed to refresh devices: {e}")
st.rerun()
input_device = None
analog_names = [d['name'] for d in analog_devices]
else:
analog_devices = []
analog_names = []
if not is_streaming:
if analog_names:
default_r1_idx = 0
input_device1 = st.selectbox(
"Input Device (Radio 1)",
analog_names,
index=default_r1_idx,
)
else:
col1, col2 = st.columns([3, 1], vertical_alignment="bottom")
with col1:
selected_option = st.selectbox(
"Input Device",
input_options,
index=input_options.index(default_input_label) if default_input_label in input_options else 0
input_device1 = None
else:
input_device1 = saved_settings.get('input_device')
st.selectbox(
"Input Device (Radio 1)",
[input_device1 or "No device selected"],
index=0,
disabled=True,
help="Stop the stream to change the input device."
)
# --- Radio 2 controls ---
st.subheader("Radio 2")
# If the backend reports that the secondary radio is currently streaming,
# initialize the checkbox to checked so the UI reflects the active state
# when the frontend is loaded.
radio2_enabled_default = secondary_is_streaming
radio2_enabled = st.checkbox(
"Enable Radio 2",
value=radio2_enabled_default,
help="Activate a second analog radio with its own quality and timing settings."
)
if radio2_enabled:
quality2 = st.selectbox(
"Stream Quality (Radio 2)",
quality_options,
index=quality_options.index(default_quality),
help="Select the audio sampling rate for Radio 2."
)
stream_passwort2 = st.text_input(
"Stream Passwort (Radio 2)",
value="",
type="password",
help="Optional: Set a broadcast code for Radio 2."
)
col_r2_flags1, col_r2_flags2, col_r2_pdelay, col_r2_rtn = st.columns([1, 1, 0.7, 0.6], gap="small")
with col_r2_flags1:
assisted_listening2 = st.checkbox(
"Assistive listening (R2)",
value=bool(saved_settings.get('assisted_listening_stream', False)),
help="tells the receiver that this is an assistive listening stream"
)
with col_r2_flags2:
immediate_rendering2 = st.checkbox(
"Immediate rendering (R2)",
value=bool(saved_settings.get('immediate_rendering', False)),
help="tells the receiver to ignore presentation delay and render immediately if possible."
)
with col_r2_pdelay:
presentation_delay_ms2 = st.number_input(
"Delay (ms, R2)",
min_value=10, max_value=200, step=5, value=default_pdelay_ms,
help="Delay between capture and presentation for Radio 2."
)
with col_r2_rtn:
rtn2 = st.selectbox(
"RTN (R2)", options=rtn_options, index=rtn_options.index(default_rtn_clamped),
help="Number of ISO retransmissions for Radio 2."
)
col_r2_name, col_r2_lang = st.columns([2, 1])
with col_r2_name:
stream_name2 = st.text_input(
"Channel Name (Radio 2)",
value=f"{default_name}_2",
help="Name for the second analog radio (Radio 2)."
)
with col_r2_lang:
language2 = st.text_input(
"Language (ISO 639-3) (Radio 2)",
value=default_lang,
help="Language code for Radio 2."
)
program_info2 = st.text_input(
"Program Info (Radio 2)",
value=default_program_info,
help="Program information for Radio 2."
)
if not is_streaming:
if analog_names:
default_r2_idx = 1 if len(analog_names) > 1 else 0
input_device2 = st.selectbox(
"Input Device (Radio 2)",
analog_names,
index=default_r2_idx,
)
with col2:
else:
input_device2 = None
else:
input_device2 = saved_settings.get('input_device')
st.selectbox(
"Input Device (Radio 2)",
[input_device2 or "No device selected"],
index=0,
disabled=True,
help="Stop the stream to change the input device."
)
radio2_cfg = {
'id': 1002,
'name': stream_name2,
'program_info': program_info2,
'language': language2,
'input_device': input_device2,
'quality': quality2,
'stream_passwort': stream_passwort2,
'assisted_listening': assisted_listening2,
'immediate_rendering': immediate_rendering2,
'presentation_delay_ms': presentation_delay_ms2,
'rtn': rtn2,
}
radio1_cfg = {
'id': 1001,
'name': stream_name1,
'program_info': program_info1,
'language': language1,
'input_device': input_device1,
'quality': quality1,
'stream_passwort': stream_passwort1,
'assisted_listening': assisted_listening1,
'immediate_rendering': immediate_rendering1,
'presentation_delay_ms': presentation_delay_ms1,
'rtn': rtn1,
}
else:
# USB/Network: single set of controls shared with the single channel
quality_options = list(QUALITY_MAP.keys())
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
quality = st.selectbox(
"Stream Quality (Sampling Rate)",
quality_options,
index=quality_options.index(default_quality),
help="Select the audio sampling rate for the stream. Lower rates may improve compatibility."
)
stream_passwort = st.text_input(
"Stream Passwort",
value="",
type="password",
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
)
col_flags1, col_flags2, col_pdelay, col_rtn = st.columns([1, 1, 0.7, 0.6], gap="small")
with col_flags1:
assisted_listening = st.checkbox(
"Assistive listening",
value=bool(saved_settings.get('assisted_listening_stream', False)),
help="tells the receiver that this is an assistive listening stream"
)
with col_flags2:
immediate_rendering = st.checkbox(
"Immediate rendering",
value=bool(saved_settings.get('immediate_rendering', False)),
help="tells the receiver to ignore presentation delay and render immediately if possible."
)
default_pdelay = int(saved_settings.get('presentation_delay_us', 40000) or 40000)
with col_pdelay:
default_pdelay_ms = max(10, min(200, default_pdelay // 1000))
presentation_delay_ms = st.number_input(
"Delay (ms)",
min_value=10, max_value=200, step=5, value=default_pdelay_ms,
help="Delay between capture and presentation for receivers."
)
default_rtn = int(saved_settings.get('rtn', 4) or 4)
with col_rtn:
rtn_options = [1,2,3,4]
default_rtn_clamped = min(4, max(1, default_rtn))
rtn = st.selectbox(
"RTN", options=rtn_options, index=rtn_options.index(default_rtn_clamped),
help="Number of ISO retransmissions (higher improves robustness at cost of airtime)."
)
stream_name = st.text_input(
"Channel Name",
value=default_name,
help="The primary name for your broadcast. Like the SSID of a WLAN, it identifies your stream for receivers."
)
program_info = st.text_input(
"Program Info",
value=default_program_info,
help="Additional details about the broadcast program, such as its content or purpose. Shown to receivers for more context."
)
language = st.text_input(
"Language (ISO 639-3)",
value=default_lang,
help="Three-letter language code (e.g., 'eng' for English, 'deu' for German). Used by receivers to display the language of the stream. See: https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes"
)
if audio_mode in ("USB", "Network"):
if not is_streaming:
try:
endpoint = "/audio_inputs_pw_usb" if audio_mode == "USB" else "/audio_inputs_pw_network"
resp = requests.get(f"{BACKEND_URL}{endpoint}")
device_list = resp.json().get('inputs', [])
except Exception as e:
st.error(f"Failed to fetch devices: {e}")
device_list = []
if audio_mode == "USB":
device_list = [d for d in device_list if d.get('name') not in ('ch1', 'ch2')]
input_options = [f"{d['name']} [{d['id']}]" for d in device_list]
option_name_map = {f"{d['name']} [{d['id']}]": d['name'] for d in device_list}
device_names = [d['name'] for d in device_list]
default_input_name = saved_settings.get('input_device')
if default_input_name not in device_names and device_names:
default_input_name = device_names[0]
default_input_label = None
for label, name in option_name_map.items():
if name == default_input_name:
default_input_label = label
break
if not input_options:
warn_text = (
"No USB audio input devices found. Connect a USB input and click Refresh."
if audio_mode == "USB" else
"No AES67/Network inputs found."
)
st.warning(warn_text)
if st.button("Refresh", disabled=is_streaming):
try:
r = requests.post(f"{BACKEND_URL}/refresh_audio_devices", timeout=8)
@@ -469,21 +703,38 @@ else:
except Exception as e:
st.error(f"Failed to refresh devices: {e}")
st.rerun()
# Send only the device name to backend
input_device = option_name_map.get(selected_option)
input_device = None
else:
col1, col2 = st.columns([3, 1], vertical_alignment="bottom")
with col1:
selected_option = st.selectbox(
"Input Device",
input_options,
index=input_options.index(default_input_label) if default_input_label in input_options else 0
)
with col2:
if st.button("Refresh", disabled=is_streaming):
try:
r = requests.post(f"{BACKEND_URL}/refresh_audio_devices", timeout=8)
if not r.ok:
st.error(f"Failed to refresh: {r.text}")
except Exception as e:
st.error(f"Failed to refresh devices: {e}")
st.rerun()
input_device = option_name_map.get(selected_option)
else:
input_device = saved_settings.get('input_device')
current_label = input_device or "No device selected"
st.selectbox(
"Input Device",
[current_label],
index=0,
disabled=True,
help="Stop the stream to change the input device."
)
else:
# When streaming, keep showing the current selection but lock editing.
input_device = saved_settings.get('input_device')
current_label = input_device or "No device selected"
st.selectbox(
"Input Device",
[current_label],
index=0,
disabled=True,
help="Stop the stream to change the input device."
)
else:
input_device = None
input_device = None
start_stream, stop_stream = render_stream_controls(is_streaming, "Start Auracast", "Stop Auracast", running_mode)
if stop_stream:
@@ -499,48 +750,104 @@ else:
if start_stream:
# Always send stop to ensure backend is in a clean state, regardless of current status
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
#if r['was_running']:
# st.success("Stream Stopped!")
# Small pause lets backend fully release audio devices before re-init
time.sleep(1)
# Prepare config using the model (do NOT send qos_config, only relevant fields)
q = QUALITY_MAP[quality]
config = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport='', # is set in backend
assisted_listening_stream=assisted_listening,
immediate_rendering=immediate_rendering,
presentation_delay_us=int(presentation_delay_ms * 1000),
qos_config=auracast_config.AuracastQoSConfig(
iso_int_multiple_10ms=1,
number_of_retransmissions=int(rtn),
max_transport_latency_ms=int(rtn)*10 + 3,
),
bigs = [
auracast_config.AuracastBigConfig(
code=(stream_passwort.strip() or None),
name=stream_name,
program_info=program_info,
language=language,
audio_source=(f"device:{input_device}"),
input_format=(f"int16le,{q['rate']},1"),
iso_que_len=1,
sampling_frequency=q['rate'],
octets_per_frame=q['octets'],
),
]
)
try:
r = requests.post(f"{BACKEND_URL}/init", json=config.model_dump())
if r.status_code == 200:
is_started = True
else:
st.error(f"Failed to initialize: {r.text}")
except Exception as e:
st.error(f"Error: {e}")
if audio_mode == "Analog":
# Build separate configs per radio, each with its own quality and QoS parameters.
is_started = False
def _build_group_from_radio(cfg: dict) -> auracast_config.AuracastConfigGroup | None:
if not cfg or not cfg.get('input_device'):
return None
q = QUALITY_MAP[cfg['quality']]
return auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport='', # is set in backend
assisted_listening_stream=bool(cfg['assisted_listening']),
immediate_rendering=bool(cfg['immediate_rendering']),
presentation_delay_us=int(cfg['presentation_delay_ms'] * 1000),
qos_config=auracast_config.AuracastQoSConfig(
iso_int_multiple_10ms=1,
number_of_retransmissions=int(cfg['rtn']),
max_transport_latency_ms=int(cfg['rtn']) * 10 + 3,
),
bigs=[
auracast_config.AuracastBigConfig(
id=cfg.get('id', 123456),
code=(cfg['stream_passwort'].strip() or None),
name=cfg['name'],
program_info=cfg['program_info'],
language=cfg['language'],
audio_source=f"device:{cfg['input_device']}",
input_format=f"int16le,{q['rate']},1",
iso_que_len=1,
sampling_frequency=q['rate'],
octets_per_frame=q['octets'],
)
],
)
# Radio 1 (always active if a device is selected)
config1 = _build_group_from_radio(radio1_cfg)
# Radio 2 (optional)
config2 = _build_group_from_radio(radio2_cfg) if radio2_enabled else None
try:
if config1 is not None:
r1 = requests.post(f"{BACKEND_URL}/init", json=config1.model_dump())
if r1.status_code == 200:
is_started = True
else:
st.error(f"Failed to initialize Radio 1: {r1.text}")
else:
st.error("Radio 1 has no valid input device configured.")
if config2 is not None:
r2 = requests.post(f"{BACKEND_URL}/init2", json=config2.model_dump())
if r2.status_code != 200:
st.error(f"Failed to initialize Radio 2: {r2.text}")
except Exception as e:
st.error(f"Error while starting Analog radios: {e}")
else:
# USB/Network: single config as before, using shared controls
q = QUALITY_MAP[quality]
config = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport='', # is set in backend
assisted_listening_stream=assisted_listening,
immediate_rendering=immediate_rendering,
presentation_delay_us=int(presentation_delay_ms * 1000),
qos_config=auracast_config.AuracastQoSConfig(
iso_int_multiple_10ms=1,
number_of_retransmissions=int(rtn),
max_transport_latency_ms=int(rtn)*10 + 3,
),
bigs=[
auracast_config.AuracastBigConfig(
code=(stream_passwort.strip() or None),
name=stream_name,
program_info=program_info,
language=language,
audio_source=(f"device:{input_device}"),
input_format=(f"int16le,{q['rate']},1"),
iso_que_len=1,
sampling_frequency=q['rate'],
octets_per_frame=q['octets'],
),
],
)
try:
r = requests.post(f"{BACKEND_URL}/init", json=config.model_dump())
if r.status_code == 200:
is_started = True
else:
st.error(f"Failed to initialize: {r.text}")
except Exception as e:
st.error(f"Error: {e}")
# Centralized rerun based on start/stop outcomes
if is_started or is_stopped:
@@ -563,6 +870,20 @@ if is_started or is_stopped:
############################
with st.expander("System control", expanded=False):
st.subheader("System temperatures")
temp_col1, temp_col2, temp_col3 = st.columns([1, 1, 1])
with temp_col1:
refresh_temps = st.button("Refresh")
try:
case_temp = read_case_temp()
cpu_temp = read_cpu_temp()
with temp_col2:
st.write(f"CPU: {cpu_temp} °C")
with temp_col3:
st.write(f"Case: {case_temp} °C")
except Exception as e:
st.warning(f"Could not read temperatures: {e}")
st.subheader("Change password")
if is_pw_disabled():
st.info("Frontend password protection is disabled via DISABLE_FRONTEND_PW.")

File diff suppressed because it is too large Load Diff