Compare commits
5 Commits
sidecar-v1
...
v2.0.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d220158dd7 | ||
|
|
8670e19acc | ||
|
|
812cc4ac5e | ||
|
|
4aa19eee86 | ||
|
|
b8dfe0f1ba |
@@ -125,6 +125,8 @@ def test_apply_settings_no_reload_when_same(controller):
|
||||
# Ensure config returns the same values
|
||||
controller.config.set("transcription.model", "base.en")
|
||||
controller.config.set("transcription.device", "auto")
|
||||
# Remote mode must also match (no engine means current mode is 'local')
|
||||
controller.config.set("remote.mode", "local")
|
||||
|
||||
controller.reload_engine = MagicMock(return_value=(True, "reloaded"))
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ transcription:
|
||||
|
||||
server_sync:
|
||||
enabled: false
|
||||
url: "http://localhost:3000/api/send"
|
||||
url: ""
|
||||
room: "default"
|
||||
passphrase: ""
|
||||
# Font settings are now in the display section (shared for local and server sync)
|
||||
@@ -69,7 +69,7 @@ web_server:
|
||||
host: "127.0.0.1"
|
||||
|
||||
remote:
|
||||
mode: local # local | managed | byok
|
||||
mode: byok # local | managed | byok
|
||||
server_url: "" # Proxy server URL for managed mode (e.g., wss://your-proxy.com)
|
||||
auth_token: "" # JWT stored after login (managed mode)
|
||||
byok_api_key: "" # Deepgram API key for BYOK mode
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "local-transcription",
|
||||
"private": true,
|
||||
"version": "2.0.13",
|
||||
"version": "2.0.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -703,6 +703,36 @@ app.post('/api/send', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Create room explicitly (no transcription needed)
|
||||
app.post('/api/create-room', async (req, res) => {
|
||||
try {
|
||||
const { room, passphrase } = req.body;
|
||||
|
||||
if (!room || !passphrase) {
|
||||
return res.status(400).json({ error: 'Missing room or passphrase' });
|
||||
}
|
||||
|
||||
// Check if room already exists
|
||||
const existing = await loadRoom(room);
|
||||
if (existing) {
|
||||
const valid = await verifyPassphrase(room, passphrase);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Room exists with different passphrase' });
|
||||
}
|
||||
return res.json({ status: 'ok', room, created: false, message: 'Room already exists' });
|
||||
}
|
||||
|
||||
// Create the room (verifyPassphrase creates it if it doesn't exist)
|
||||
await verifyPassphrase(room, passphrase);
|
||||
|
||||
console.log(`[Room] Created room "${room}"`);
|
||||
res.json({ status: 'ok', room, created: true });
|
||||
} catch (err) {
|
||||
console.error('Error in /api/create-room:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// List transcriptions
|
||||
app.get('/api/list', async (req, res) => {
|
||||
try {
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -1881,7 +1881,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "local-transcription"
|
||||
version = "2.0.8"
|
||||
version = "2.0.12"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "local-transcription"
|
||||
version = "2.0.13"
|
||||
version = "2.0.15"
|
||||
description = "Real-time speech-to-text transcription for streamers"
|
||||
authors = ["Local Transcription Contributors"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Local Transcription",
|
||||
"version": "2.0.13",
|
||||
"version": "2.0.15",
|
||||
"identifier": "net.anhonesthost.local-transcription",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { backendStore } from "$lib/stores/backend";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { transcriptionStore } from "$lib/stores/transcriptions";
|
||||
|
||||
let isTranscribing = $derived(backendStore.appState === "transcribing");
|
||||
@@ -8,6 +9,16 @@
|
||||
);
|
||||
let isLoading = $state(false);
|
||||
|
||||
let remoteMode = $derived(configStore.config.remote.mode);
|
||||
let byokApiKey = $derived(configStore.config.remote.byok_api_key);
|
||||
let authToken = $derived(configStore.config.remote.auth_token);
|
||||
|
||||
let cloudConfigured = $derived(
|
||||
remoteMode === "local" ||
|
||||
(remoteMode === "byok" && byokApiKey.trim() !== "") ||
|
||||
(remoteMode === "managed" && authToken.trim() !== "")
|
||||
);
|
||||
|
||||
let errorMessage = $state("");
|
||||
|
||||
async function toggleTranscription() {
|
||||
@@ -94,7 +105,7 @@
|
||||
<button
|
||||
class={isTranscribing ? "danger" : "primary"}
|
||||
onclick={toggleTranscription}
|
||||
disabled={!isReady || isLoading}
|
||||
disabled={!isReady || isLoading || !cloudConfigured}
|
||||
>
|
||||
{#if isLoading}
|
||||
...
|
||||
@@ -116,6 +127,18 @@
|
||||
{#if errorMessage}
|
||||
<span class="error-msg">{errorMessage}</span>
|
||||
{/if}
|
||||
|
||||
{#if !cloudConfigured && isReady}
|
||||
<div class="cloud-warning">
|
||||
{#if remoteMode === "byok"}
|
||||
<span>API key required. Get one at
|
||||
<a href="https://console.deepgram.com" target="_blank" rel="noopener">console.deepgram.com</a>,
|
||||
then enter it in Settings.</span>
|
||||
{:else if remoteMode === "managed"}
|
||||
<span>Login required. Open Settings to log in.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -125,6 +148,18 @@
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.cloud-warning {
|
||||
font-size: 12px;
|
||||
color: #ff9800;
|
||||
margin-left: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cloud-warning a {
|
||||
color: #4fc3f7;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -46,6 +46,14 @@
|
||||
let managedPassword = $state("");
|
||||
let autoCheckUpdates = $state(true);
|
||||
|
||||
let isCloudMode = $derived(remoteMode === "managed" || remoteMode === "byok");
|
||||
|
||||
// Room creation / join state
|
||||
let shareCode = $state("");
|
||||
let joinCode = $state("");
|
||||
let roomCreating = $state(false);
|
||||
let roomCreateMessage = $state("");
|
||||
|
||||
let saving = $state(false);
|
||||
let saveMessage = $state("");
|
||||
|
||||
@@ -266,6 +274,110 @@
|
||||
}
|
||||
}
|
||||
|
||||
const CAPTION_SERVER = "https://caption.shadowdao.com";
|
||||
|
||||
function generateRandomName(): string {
|
||||
const adjectives = ['swift', 'bright', 'cosmic', 'electric', 'turbo', 'mega', 'ultra', 'super', 'hyper', 'alpha'];
|
||||
const nouns = ['phoenix', 'dragon', 'tiger', 'falcon', 'comet', 'storm', 'blaze', 'thunder', 'frost', 'nebula'];
|
||||
const num = Math.floor(Math.random() * 10000);
|
||||
return `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${nouns[Math.floor(Math.random() * nouns.length)]}-${num}`;
|
||||
}
|
||||
|
||||
function generateRandomPassphrase(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 16; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function encodeShareCode(url: string, room: string, passphrase: string): string {
|
||||
return btoa(JSON.stringify({ url, room, passphrase }));
|
||||
}
|
||||
|
||||
function decodeShareCode(code: string): { url: string; room: string; passphrase: string } | null {
|
||||
try {
|
||||
const json = JSON.parse(atob(code.trim()));
|
||||
if (json.url && json.room && json.passphrase) {
|
||||
return json;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRoom() {
|
||||
roomCreating = true;
|
||||
roomCreateMessage = "";
|
||||
shareCode = "";
|
||||
|
||||
const room = generateRandomName();
|
||||
const passphrase = generateRandomPassphrase();
|
||||
const serverSendUrl = `${CAPTION_SERVER}/api/send`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${CAPTION_SERVER}/api/create-room`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ room, passphrase }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ error: "Request failed" }));
|
||||
roomCreateMessage = `Error: ${err.error || resp.statusText}`;
|
||||
return;
|
||||
}
|
||||
|
||||
syncUrl = serverSendUrl;
|
||||
syncRoom = room;
|
||||
syncPassphrase = passphrase;
|
||||
syncEnabled = true;
|
||||
|
||||
shareCode = encodeShareCode(serverSendUrl, room, passphrase);
|
||||
roomCreateMessage = "Room created! Share the code below with others.";
|
||||
} catch (err) {
|
||||
roomCreateMessage = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
} finally {
|
||||
roomCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleJoinRoom() {
|
||||
const decoded = decodeShareCode(joinCode);
|
||||
if (!decoded) {
|
||||
roomCreateMessage = "Invalid share code. Please check and try again.";
|
||||
return;
|
||||
}
|
||||
syncUrl = decoded.url;
|
||||
syncRoom = decoded.room;
|
||||
syncPassphrase = decoded.passphrase;
|
||||
syncEnabled = true;
|
||||
joinCode = "";
|
||||
roomCreateMessage = "Room joined! Fields have been auto-filled.";
|
||||
}
|
||||
|
||||
async function handleShareCurrentRoom() {
|
||||
const code = encodeShareCode(syncUrl, syncRoom, syncPassphrase);
|
||||
shareCode = code;
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
roomCreateMessage = "Share code copied to clipboard!";
|
||||
} catch {
|
||||
roomCreateMessage = "Share code generated. Copy it from the field below.";
|
||||
}
|
||||
}
|
||||
|
||||
async function copyShareCode() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareCode);
|
||||
roomCreateMessage = "Share code copied to clipboard!";
|
||||
} catch {
|
||||
roomCreateMessage = "Failed to copy. Please select and copy manually.";
|
||||
}
|
||||
}
|
||||
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains("settings-overlay")) {
|
||||
handleCancel();
|
||||
@@ -327,7 +439,90 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Transcription Settings -->
|
||||
<!-- Remote Transcription (moved up for cloud-first UX) -->
|
||||
<section class="settings-section">
|
||||
<h3>Transcription Mode</h3>
|
||||
<div class="radio-group">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="remote-mode"
|
||||
value="byok"
|
||||
bind:group={remoteMode}
|
||||
/>
|
||||
Cloud (Deepgram)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="remote-mode"
|
||||
value="managed"
|
||||
bind:group={remoteMode}
|
||||
/>
|
||||
Managed Service
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="remote-mode"
|
||||
value="local"
|
||||
bind:group={remoteMode}
|
||||
/>
|
||||
Local (Whisper)
|
||||
</label>
|
||||
</div>
|
||||
{#if remoteMode === "byok"}
|
||||
<div class="field">
|
||||
<label for="byok-key">Deepgram API Key</label>
|
||||
<input
|
||||
id="byok-key"
|
||||
type="password"
|
||||
bind:value={byokApiKey}
|
||||
placeholder="Enter your Deepgram API key"
|
||||
/>
|
||||
<p style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
|
||||
Get a key at <a href="https://console.deepgram.com" target="_blank" rel="noopener" style="color: var(--accent-blue);">console.deepgram.com</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if remoteMode === "managed"}
|
||||
<div class="field">
|
||||
<label for="remote-url">Server URL</label>
|
||||
<input
|
||||
id="remote-url"
|
||||
type="url"
|
||||
bind:value={remoteServerUrl}
|
||||
placeholder="wss://your-proxy.com"
|
||||
/>
|
||||
</div>
|
||||
<div class="managed-auth">
|
||||
<div class="field">
|
||||
<label for="managed-email">Email</label>
|
||||
<input
|
||||
id="managed-email"
|
||||
type="email"
|
||||
bind:value={managedEmail}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="managed-password">Password</label>
|
||||
<input
|
||||
id="managed-password"
|
||||
type="password"
|
||||
bind:value={managedPassword}
|
||||
/>
|
||||
</div>
|
||||
<div class="auth-buttons">
|
||||
<button onclick={handleManagedLogin}>Login</button>
|
||||
<button onclick={handleManagedRegister}>Register</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if !isCloudMode}
|
||||
<!-- Transcription Settings (local Whisper only) -->
|
||||
<section class="settings-section">
|
||||
<h3>Transcription Settings</h3>
|
||||
<div class="field">
|
||||
@@ -473,6 +668,7 @@
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Display Settings -->
|
||||
<section class="settings-section">
|
||||
@@ -628,11 +824,11 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Server Sync -->
|
||||
<!-- Server Sync (Shared Captions) -->
|
||||
<section class="settings-section">
|
||||
<h3>Server Sync</h3>
|
||||
<h3>Shared Captions</h3>
|
||||
<div class="field-row">
|
||||
<label for="sync-enabled">Enable Server Sync</label>
|
||||
<label for="sync-enabled">Enable Shared Captions</label>
|
||||
<input
|
||||
id="sync-enabled"
|
||||
type="checkbox"
|
||||
@@ -640,13 +836,57 @@
|
||||
/>
|
||||
</div>
|
||||
{#if syncEnabled}
|
||||
<div class="room-actions">
|
||||
<div class="room-buttons-row">
|
||||
<button
|
||||
onclick={handleCreateRoom}
|
||||
disabled={roomCreating}
|
||||
class="secondary"
|
||||
>
|
||||
{roomCreating ? "Creating..." : "Create Room"}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleShareCurrentRoom}
|
||||
disabled={!syncUrl.trim() || !syncRoom.trim() || !syncPassphrase.trim()}
|
||||
class="secondary"
|
||||
>
|
||||
Share Current Room
|
||||
</button>
|
||||
</div>
|
||||
<div class="join-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={joinCode}
|
||||
placeholder="Paste share code to join"
|
||||
class="join-input"
|
||||
/>
|
||||
<button onclick={handleJoinRoom} disabled={!joinCode.trim()} class="secondary">
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if roomCreateMessage}
|
||||
<p class="room-message" class:error={roomCreateMessage.startsWith("Error")}>{roomCreateMessage}</p>
|
||||
{/if}
|
||||
|
||||
{#if shareCode}
|
||||
<div class="share-code-box">
|
||||
<label>Share Code</label>
|
||||
<div class="share-code-row">
|
||||
<input type="text" value={shareCode} readonly class="share-code-input" />
|
||||
<button onclick={copyShareCode} class="secondary">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="sync-url">Server URL</label>
|
||||
<input
|
||||
id="sync-url"
|
||||
type="url"
|
||||
bind:value={syncUrl}
|
||||
placeholder="http://localhost:3000/api/send"
|
||||
placeholder="https://caption.shadowdao.com/api/send"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -664,90 +904,6 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Remote Transcription -->
|
||||
<section class="settings-section">
|
||||
<h3>Remote Transcription</h3>
|
||||
<div class="radio-group">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="remote-mode"
|
||||
value="local"
|
||||
bind:group={remoteMode}
|
||||
/>
|
||||
Local
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="remote-mode"
|
||||
value="managed"
|
||||
bind:group={remoteMode}
|
||||
/>
|
||||
Managed
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="remote-mode"
|
||||
value="byok"
|
||||
bind:group={remoteMode}
|
||||
/>
|
||||
BYOK (Bring Your Own Key)
|
||||
</label>
|
||||
</div>
|
||||
{#if remoteMode === "managed"}
|
||||
<div class="field">
|
||||
<label for="remote-url">Server URL</label>
|
||||
<input
|
||||
id="remote-url"
|
||||
type="url"
|
||||
bind:value={remoteServerUrl}
|
||||
placeholder="wss://your-proxy.com"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if remoteMode === "byok"}
|
||||
<div class="field">
|
||||
<label for="byok-key">Deepgram API Key</label>
|
||||
<input
|
||||
id="byok-key"
|
||||
type="password"
|
||||
bind:value={byokApiKey}
|
||||
placeholder="Enter your Deepgram API key"
|
||||
/>
|
||||
<p style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
|
||||
Get a key at <a href="https://console.deepgram.com" target="_blank" rel="noopener" style="color: var(--accent-blue);">console.deepgram.com</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if remoteMode === "managed"}
|
||||
<div class="managed-auth">
|
||||
<div class="field">
|
||||
<label for="managed-email">Email</label>
|
||||
<input
|
||||
id="managed-email"
|
||||
type="email"
|
||||
bind:value={managedEmail}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="managed-password">Password</label>
|
||||
<input
|
||||
id="managed-password"
|
||||
type="password"
|
||||
bind:value={managedPassword}
|
||||
/>
|
||||
</div>
|
||||
<div class="auth-buttons">
|
||||
<button onclick={handleManagedLogin}>Login</button>
|
||||
<button onclick={handleManagedRegister}>Register</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Updates -->
|
||||
<section class="settings-section">
|
||||
<h3>Updates</h3>
|
||||
@@ -943,6 +1099,78 @@
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.room-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.room-buttons-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.join-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.join-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.room-message {
|
||||
font-size: 12px;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.room-message.error {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.share-code-box {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.share-code-box label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.share-code-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.share-code-input {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.secondary:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent-red, #f44336);
|
||||
|
||||
@@ -107,7 +107,7 @@ function getDefaultConfig(): AppConfig {
|
||||
},
|
||||
server_sync: {
|
||||
enabled: false,
|
||||
url: "http://localhost:3000/api/send",
|
||||
url: "",
|
||||
room: "default",
|
||||
passphrase: "",
|
||||
},
|
||||
@@ -128,7 +128,7 @@ function getDefaultConfig(): AppConfig {
|
||||
},
|
||||
web_server: { port: 8080, host: "127.0.0.1" },
|
||||
remote: {
|
||||
mode: "local",
|
||||
mode: "byok",
|
||||
server_url: "",
|
||||
auth_token: "",
|
||||
byok_api_key: "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Version information for Local Transcription."""
|
||||
|
||||
__version__ = "2.0.13"
|
||||
__version_info__ = (2, 0, 13)
|
||||
__version__ = "2.0.15"
|
||||
__version_info__ = (2, 0, 15)
|
||||
|
||||
# Version history:
|
||||
# 1.4.0 - Auto-update feature:
|
||||
|
||||
Reference in New Issue
Block a user