Compare commits
7 Commits
sidecar-v1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d263be2ac1 | ||
|
|
1c8c6ad7e8 | ||
|
|
023bc0218b | ||
|
|
634506f902 | ||
|
|
8c7f4e8008 | ||
|
|
b8d718caa6 | ||
|
|
d92005bf95 |
@@ -73,8 +73,15 @@ class APIServer:
|
|||||||
original_state_cb = self.controller.on_state_changed
|
original_state_cb = self.controller.on_state_changed
|
||||||
|
|
||||||
def on_state_changed(state: str, message: str):
|
def on_state_changed(state: str, message: str):
|
||||||
|
# Isolate the upstream callback so a failure there (e.g. a
|
||||||
|
# broken stdout pipe in main_headless) cannot propagate into
|
||||||
|
# _set_state and tear down engine init / reload_engine /
|
||||||
|
# apply_settings request handling.
|
||||||
if original_state_cb:
|
if original_state_cb:
|
||||||
|
try:
|
||||||
original_state_cb(state, message)
|
original_state_cb(state, message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._broadcast_control({"type": "state_changed", "state": state, "message": message})
|
self._broadcast_control({"type": "state_changed", "state": state, "message": message})
|
||||||
|
|
||||||
self.controller.on_state_changed = on_state_changed
|
self.controller.on_state_changed = on_state_changed
|
||||||
@@ -273,6 +280,7 @@ class APIServer:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
ctrl.config.set('remote.auth_token', data.get('token', ''))
|
ctrl.config.set('remote.auth_token', data.get('token', ''))
|
||||||
ctrl.config.set('remote.server_url', req.server_url)
|
ctrl.config.set('remote.server_url', req.server_url)
|
||||||
|
ctrl.config.set('remote.email', req.email)
|
||||||
return {"status": "ok", "token": data.get('token', '')}
|
return {"status": "ok", "token": data.get('token', '')}
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=resp.status_code, detail=resp.text)
|
raise HTTPException(status_code=resp.status_code, detail=resp.text)
|
||||||
|
|||||||
@@ -276,7 +276,6 @@ class AppController:
|
|||||||
self.current_model_size = model
|
self.current_model_size = model
|
||||||
self.current_device_config = device_config
|
self.current_device_config = device_config
|
||||||
|
|
||||||
user_name = self.config.get('user.name', 'User')
|
|
||||||
continuous_mode = self.config.get('transcription.continuous_mode', False)
|
continuous_mode = self.config.get('transcription.continuous_mode', False)
|
||||||
|
|
||||||
if continuous_mode:
|
if continuous_mode:
|
||||||
@@ -293,7 +292,6 @@ class AppController:
|
|||||||
if remote_mode in ('managed', 'byok'):
|
if remote_mode in ('managed', 'byok'):
|
||||||
self.transcription_engine = DeepgramTranscriptionEngine(
|
self.transcription_engine = DeepgramTranscriptionEngine(
|
||||||
config=self.config,
|
config=self.config,
|
||||||
user_name=user_name,
|
|
||||||
input_device_index=audio_device,
|
input_device_index=audio_device,
|
||||||
)
|
)
|
||||||
self.transcription_engine.set_callbacks(
|
self.transcription_engine.set_callbacks(
|
||||||
@@ -343,7 +341,7 @@ class AppController:
|
|||||||
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
||||||
no_log_file=self.config.get('transcription.no_log_file', True),
|
no_log_file=self.config.get('transcription.no_log_file', True),
|
||||||
input_device_index=audio_device,
|
input_device_index=audio_device,
|
||||||
user_name=user_name,
|
app_config=self.config,
|
||||||
)
|
)
|
||||||
self.transcription_engine.set_callbacks(
|
self.transcription_engine.set_callbacks(
|
||||||
realtime_callback=self._on_realtime_transcription,
|
realtime_callback=self._on_realtime_transcription,
|
||||||
|
|||||||
@@ -75,10 +75,16 @@ def main():
|
|||||||
# Create controller and initialize
|
# Create controller and initialize
|
||||||
controller = AppController(config=config)
|
controller = AppController(config=config)
|
||||||
|
|
||||||
# Wire a state callback that prints the ready event
|
# Wire a state callback that prints state events for the parent
|
||||||
|
# process to read. Stdout writes can fail with EINVAL on Windows
|
||||||
|
# when the parent stops reading the sidecar pipe; swallow those
|
||||||
|
# so the engine state machine isn't taken down by a logging path.
|
||||||
def on_state_changed(state, message):
|
def on_state_changed(state, message):
|
||||||
event = {"event": "state", "state": state, "message": message}
|
event = {"event": "state", "state": state, "message": message}
|
||||||
|
try:
|
||||||
print(json.dumps(event), flush=True)
|
print(json.dumps(event), flush=True)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
controller.on_state_changed = on_state_changed
|
controller.on_state_changed = on_state_changed
|
||||||
|
|
||||||
|
|||||||
@@ -36,18 +36,16 @@ class DeepgramTranscriptionEngine:
|
|||||||
# Construction / configuration
|
# Construction / configuration
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def __init__(self, config, user_name: str = "User", input_device_index: Optional[int] = None):
|
def __init__(self, config, input_device_index: Optional[int] = None):
|
||||||
"""
|
"""
|
||||||
Initialise the engine from a :class:`client.config.Config` object.
|
Initialise the engine from a :class:`client.config.Config` object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Application ``Config`` instance.
|
config: Application ``Config`` instance.
|
||||||
user_name: Display name attached to transcriptions.
|
|
||||||
input_device_index: Index of the audio input device to use
|
input_device_index: Index of the audio input device to use
|
||||||
(``None`` for the system default).
|
(``None`` for the system default).
|
||||||
"""
|
"""
|
||||||
self.config = config
|
self.config = config
|
||||||
self.user_name = user_name
|
|
||||||
self.input_device_index = input_device_index
|
self.input_device_index = input_device_index
|
||||||
|
|
||||||
# Mode: 'managed' (proxy) or 'byok' (direct Deepgram)
|
# Mode: 'managed' (proxy) or 'byok' (direct Deepgram)
|
||||||
@@ -454,7 +452,7 @@ class DeepgramTranscriptionEngine:
|
|||||||
text=text,
|
text=text,
|
||||||
is_final=is_final,
|
is_final=is_final,
|
||||||
timestamp=datetime.now(),
|
timestamp=datetime.now(),
|
||||||
user_name=self.user_name,
|
user_name=self.config.get('user.name', 'User'),
|
||||||
)
|
)
|
||||||
if is_final:
|
if is_final:
|
||||||
if self.final_callback:
|
if self.final_callback:
|
||||||
@@ -505,7 +503,7 @@ class DeepgramTranscriptionEngine:
|
|||||||
text=transcript,
|
text=transcript,
|
||||||
is_final=is_final,
|
is_final=is_final,
|
||||||
timestamp=datetime.now(),
|
timestamp=datetime.now(),
|
||||||
user_name=self.user_name,
|
user_name=self.config.get('user.name', 'User'),
|
||||||
)
|
)
|
||||||
if is_final:
|
if is_final:
|
||||||
if self.final_callback:
|
if self.final_callback:
|
||||||
@@ -536,10 +534,6 @@ class DeepgramTranscriptionEngine:
|
|||||||
pass
|
pass
|
||||||
self._ws = None
|
self._ws = None
|
||||||
|
|
||||||
def set_user_name(self, user_name: str):
|
|
||||||
"""Update the user name attached to future transcriptions."""
|
|
||||||
self.user_name = user_name
|
|
||||||
|
|
||||||
def is_recording_active(self) -> bool:
|
def is_recording_active(self) -> bool:
|
||||||
"""Return ``True`` if audio is currently being captured."""
|
"""Return ``True`` if audio is currently being captured."""
|
||||||
return self._is_recording
|
return self._is_recording
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ class RealtimeTranscriptionEngine:
|
|||||||
no_log_file: bool = True,
|
no_log_file: bool = True,
|
||||||
# Audio device
|
# Audio device
|
||||||
input_device_index: Optional[int] = None,
|
input_device_index: Optional[int] = None,
|
||||||
# User name
|
# App config (for reading user.name at transcription time)
|
||||||
user_name: str = ""
|
app_config=None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize RealtimeSTT transcription engine.
|
Initialize RealtimeSTT transcription engine.
|
||||||
@@ -82,7 +82,7 @@ class RealtimeTranscriptionEngine:
|
|||||||
initial_prompt: Optional prompt to guide transcription
|
initial_prompt: Optional prompt to guide transcription
|
||||||
no_log_file: Disable RealtimeSTT logging
|
no_log_file: Disable RealtimeSTT logging
|
||||||
input_device_index: Audio input device index
|
input_device_index: Audio input device index
|
||||||
user_name: User name for transcriptions
|
app_config: App Config object for reading user.name dynamically
|
||||||
"""
|
"""
|
||||||
self.model = model
|
self.model = model
|
||||||
self.language = language
|
self.language = language
|
||||||
@@ -100,7 +100,7 @@ class RealtimeTranscriptionEngine:
|
|||||||
self.enable_realtime = enable_realtime_transcription
|
self.enable_realtime = enable_realtime_transcription
|
||||||
self.realtime_model = realtime_model
|
self.realtime_model = realtime_model
|
||||||
self.realtime_processing_pause = realtime_processing_pause
|
self.realtime_processing_pause = realtime_processing_pause
|
||||||
self.user_name = user_name
|
self.app_config = app_config
|
||||||
|
|
||||||
# Callbacks
|
# Callbacks
|
||||||
self.realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None
|
self.realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None
|
||||||
@@ -162,6 +162,11 @@ class RealtimeTranscriptionEngine:
|
|||||||
self.realtime_callback = realtime_callback
|
self.realtime_callback = realtime_callback
|
||||||
self.final_callback = final_callback
|
self.final_callback = final_callback
|
||||||
|
|
||||||
|
def _get_user_name(self) -> str:
|
||||||
|
if self.app_config:
|
||||||
|
return self.app_config.get('user.name', '')
|
||||||
|
return ''
|
||||||
|
|
||||||
def _on_realtime_transcription(self, text: str):
|
def _on_realtime_transcription(self, text: str):
|
||||||
"""Internal callback for realtime transcriptions."""
|
"""Internal callback for realtime transcriptions."""
|
||||||
if self.realtime_callback and text.strip():
|
if self.realtime_callback and text.strip():
|
||||||
@@ -169,7 +174,7 @@ class RealtimeTranscriptionEngine:
|
|||||||
text=text,
|
text=text,
|
||||||
is_final=False,
|
is_final=False,
|
||||||
timestamp=datetime.now(),
|
timestamp=datetime.now(),
|
||||||
user_name=self.user_name
|
user_name=self._get_user_name()
|
||||||
)
|
)
|
||||||
self.realtime_callback(result)
|
self.realtime_callback(result)
|
||||||
|
|
||||||
@@ -180,7 +185,7 @@ class RealtimeTranscriptionEngine:
|
|||||||
text=text,
|
text=text,
|
||||||
is_final=True,
|
is_final=True,
|
||||||
timestamp=datetime.now(),
|
timestamp=datetime.now(),
|
||||||
user_name=self.user_name
|
user_name=self._get_user_name()
|
||||||
)
|
)
|
||||||
self.final_callback(result)
|
self.final_callback(result)
|
||||||
|
|
||||||
@@ -406,10 +411,6 @@ class RealtimeTranscriptionEngine:
|
|||||||
if self.is_recording:
|
if self.is_recording:
|
||||||
print("VAD settings updated. Restart transcription to apply changes.")
|
print("VAD settings updated. Restart transcription to apply changes.")
|
||||||
|
|
||||||
def set_user_name(self, user_name: str):
|
|
||||||
"""Set the user name for transcriptions."""
|
|
||||||
self.user_name = user_name
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"RealtimeTranscriptionEngine(model={self.model}, device={self.device}, running={self.is_recording})"
|
return f"RealtimeTranscriptionEngine(model={self.model}, device={self.device}, running={self.is_recording})"
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ remote:
|
|||||||
mode: byok # local | managed | byok
|
mode: byok # local | managed | byok
|
||||||
server_url: "https://transcribe.shadowdao.com" # Proxy server URL for managed mode
|
server_url: "https://transcribe.shadowdao.com" # Proxy server URL for managed mode
|
||||||
auth_token: "" # JWT stored after login (managed mode)
|
auth_token: "" # JWT stored after login (managed mode)
|
||||||
|
email: "" # Email of the logged-in managed-mode account (for UI display)
|
||||||
byok_api_key: "" # Deepgram API key for BYOK mode
|
byok_api_key: "" # Deepgram API key for BYOK mode
|
||||||
deepgram_model: nova-2 # Deepgram model to use
|
deepgram_model: nova-2 # Deepgram model to use
|
||||||
language: en-US # Language code
|
language: en-US # Language code
|
||||||
|
|||||||
@@ -401,7 +401,6 @@ class MainWindow(QMainWindow):
|
|||||||
# Use Deepgram-based remote transcription
|
# Use Deepgram-based remote transcription
|
||||||
self.transcription_engine = DeepgramTranscriptionEngine(
|
self.transcription_engine = DeepgramTranscriptionEngine(
|
||||||
config=self.config,
|
config=self.config,
|
||||||
user_name=user_name,
|
|
||||||
input_device_index=audio_device
|
input_device_index=audio_device
|
||||||
)
|
)
|
||||||
self.transcription_engine.set_callbacks(
|
self.transcription_engine.set_callbacks(
|
||||||
@@ -431,7 +430,7 @@ class MainWindow(QMainWindow):
|
|||||||
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
||||||
no_log_file=self.config.get('transcription.no_log_file', True),
|
no_log_file=self.config.get('transcription.no_log_file', True),
|
||||||
input_device_index=audio_device,
|
input_device_index=audio_device,
|
||||||
user_name=user_name
|
app_config=self.config
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up callbacks for transcription results
|
# Set up callbacks for transcription results
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class TranscriptionCLI:
|
|||||||
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
||||||
no_log_file=True,
|
no_log_file=True,
|
||||||
input_device_index=audio_device,
|
input_device_index=audio_device,
|
||||||
user_name=user_name
|
app_config=self.config
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up callbacks
|
# Set up callbacks
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "local-transcription",
|
"name": "local-transcription",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.18",
|
"version": "2.0.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "local-transcription"
|
name = "local-transcription"
|
||||||
version = "1.0.13"
|
version = "1.0.15"
|
||||||
description = "A standalone desktop application for real-time speech-to-text transcription using Whisper models"
|
description = "A standalone desktop application for real-time speech-to-text transcription using Whisper models"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "local-transcription"
|
name = "local-transcription"
|
||||||
version = "2.0.18"
|
version = "2.0.20"
|
||||||
description = "Real-time speech-to-text transcription for streamers"
|
description = "Real-time speech-to-text transcription for streamers"
|
||||||
authors = ["Local Transcription Contributors"]
|
authors = ["Local Transcription Contributors"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Local Transcription",
|
"productName": "Local Transcription",
|
||||||
"version": "2.0.18",
|
"version": "2.0.20",
|
||||||
"identifier": "net.anhonesthost.local-transcription",
|
"identifier": "net.anhonesthost.local-transcription",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
let byokApiKey = $state("");
|
let byokApiKey = $state("");
|
||||||
let managedEmail = $state("");
|
let managedEmail = $state("");
|
||||||
let managedPassword = $state("");
|
let managedPassword = $state("");
|
||||||
|
let managedLoggedIn = $state(false);
|
||||||
let autoCheckUpdates = $state(true);
|
let autoCheckUpdates = $state(true);
|
||||||
|
|
||||||
let isCloudMode = $derived(remoteMode === "managed" || remoteMode === "byok");
|
let isCloudMode = $derived(remoteMode === "managed" || remoteMode === "byok");
|
||||||
@@ -131,6 +132,8 @@
|
|||||||
remoteMode = cfg.remote.mode;
|
remoteMode = cfg.remote.mode;
|
||||||
remoteServerUrl = cfg.remote.server_url;
|
remoteServerUrl = cfg.remote.server_url;
|
||||||
byokApiKey = cfg.remote.byok_api_key ?? "";
|
byokApiKey = cfg.remote.byok_api_key ?? "";
|
||||||
|
managedEmail = cfg.remote.email ?? "";
|
||||||
|
managedLoggedIn = !!(cfg.remote.auth_token && cfg.remote.email);
|
||||||
autoCheckUpdates = cfg.updates.auto_check;
|
autoCheckUpdates = cfg.updates.auto_check;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,15 +260,37 @@
|
|||||||
|
|
||||||
const MANAGED_SERVER_URL = "https://transcribe.shadowdao.com";
|
const MANAGED_SERVER_URL = "https://transcribe.shadowdao.com";
|
||||||
|
|
||||||
|
let loginMessage = $state("");
|
||||||
|
|
||||||
async function handleManagedLogin() {
|
async function handleManagedLogin() {
|
||||||
|
loginMessage = "";
|
||||||
try {
|
try {
|
||||||
await backendStore.apiPost("/api/login", {
|
await backendStore.apiPost("/api/login", {
|
||||||
email: managedEmail,
|
email: managedEmail,
|
||||||
password: managedPassword,
|
password: managedPassword,
|
||||||
server_url: remoteServerUrl || MANAGED_SERVER_URL,
|
server_url: remoteServerUrl || MANAGED_SERVER_URL,
|
||||||
});
|
});
|
||||||
|
loginMessage = "Logged in successfully!";
|
||||||
|
managedPassword = "";
|
||||||
|
managedLoggedIn = true;
|
||||||
|
await configStore.fetchConfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Login failed:", err);
|
console.error("Login failed:", err);
|
||||||
|
loginMessage = "Login failed. Check your email and password.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleManagedLogout() {
|
||||||
|
try {
|
||||||
|
await configStore.updateConfig({
|
||||||
|
remote: { auth_token: "", email: "" },
|
||||||
|
});
|
||||||
|
managedLoggedIn = false;
|
||||||
|
managedPassword = "";
|
||||||
|
loginMessage = "";
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Logout failed:", err);
|
||||||
|
loginMessage = `Error: ${err}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +509,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if remoteMode === "managed"}
|
{#if remoteMode === "managed"}
|
||||||
<div class="managed-auth">
|
<div class="managed-auth">
|
||||||
|
{#if managedLoggedIn}
|
||||||
|
<p style="font-size: 13px; margin: 0 0 8px;">
|
||||||
|
<span style="color: var(--accent-green, #4CAF50);">✓ Logged in</span>
|
||||||
|
as <strong>{managedEmail}</strong>
|
||||||
|
</p>
|
||||||
|
<div class="auth-buttons">
|
||||||
|
<button onclick={handleManagedLogout}>Log out</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="managed-email">Email</label>
|
<label for="managed-email">Email</label>
|
||||||
<input
|
<input
|
||||||
@@ -507,6 +541,12 @@
|
|||||||
<p style="font-size: 11px; color: var(--text-muted); margin-top: 8px;">
|
<p style="font-size: 11px; color: var(--text-muted); margin-top: 8px;">
|
||||||
Don't have an account? <a href="https://transcribe.shadowdao.com/register.html" target="_blank" rel="noopener" style="color: var(--accent-blue);">Sign up here</a>
|
Don't have an account? <a href="https://transcribe.shadowdao.com/register.html" target="_blank" rel="noopener" style="color: var(--accent-blue);">Sign up here</a>
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if loginMessage}
|
||||||
|
<p style="font-size: 12px; margin-top: 6px; color: {loginMessage.startsWith('Logged') ? 'var(--accent-green, #4CAF50)' : 'var(--accent-red, #f44336)'};">
|
||||||
|
{loginMessage}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface AppConfig {
|
|||||||
mode: string;
|
mode: string;
|
||||||
server_url: string;
|
server_url: string;
|
||||||
auth_token: string;
|
auth_token: string;
|
||||||
|
email: string;
|
||||||
byok_api_key: string;
|
byok_api_key: string;
|
||||||
deepgram_model: string;
|
deepgram_model: string;
|
||||||
language: string;
|
language: string;
|
||||||
@@ -131,6 +132,7 @@ function getDefaultConfig(): AppConfig {
|
|||||||
mode: "byok",
|
mode: "byok",
|
||||||
server_url: "",
|
server_url: "",
|
||||||
auth_token: "",
|
auth_token: "",
|
||||||
|
email: "",
|
||||||
byok_api_key: "",
|
byok_api_key: "",
|
||||||
deepgram_model: "nova-2",
|
deepgram_model: "nova-2",
|
||||||
language: "en-US",
|
language: "en-US",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Version information for Local Transcription."""
|
"""Version information for Local Transcription."""
|
||||||
|
|
||||||
__version__ = "2.0.18"
|
__version__ = "2.0.20"
|
||||||
__version_info__ = (2, 0, 18)
|
__version_info__ = (2, 0, 20)
|
||||||
|
|
||||||
# Version history:
|
# Version history:
|
||||||
# 1.4.0 - Auto-update feature:
|
# 1.4.0 - Auto-update feature:
|
||||||
|
|||||||
Reference in New Issue
Block a user