Add auto-update feature with Gitea release checking

- Add UpdateChecker class to query Gitea API for latest releases
- Show update dialog with release notes when new version available
- Open browser to release page for download (handles large files)
- Allow users to skip specific versions or defer updates
- Add "Check for Updates Now" button in settings
- Check automatically on startup (respects 24-hour interval)
- Pre-configured for repo.anhonesthost.net/streamer-tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-22 17:40:13 -08:00
parent 89819f5d1b
commit b7ab57f21f
7 changed files with 425 additions and 4 deletions

View File

@@ -133,6 +133,15 @@ class Config:
'max_lines': 100, 'max_lines': 100,
'font_size': 12, 'font_size': 12,
'theme': 'dark' 'theme': 'dark'
},
'updates': {
'auto_check': True,
'gitea_url': 'https://repo.anhonesthost.net',
'owner': 'streamer-tools',
'repo': 'local-transcription',
'skipped_versions': [],
'last_check': '',
'check_interval_hours': 24
} }
} }

129
client/update_checker.py Normal file
View File

@@ -0,0 +1,129 @@
"""Update checker for Local Transcription - checks Gitea for new releases."""
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, Callable
import threading
import requests
from packaging import version
@dataclass
class ReleaseInfo:
"""Information about a release."""
version: str # Parsed version without 'v' prefix (e.g., "1.4.0")
tag_name: str # Original tag name (e.g., "v1.4.0")
release_notes: str # Release notes / body markdown
download_url: str # Browser URL for release page (html_url)
class UpdateChecker:
"""Checks for updates from a Gitea server."""
def __init__(
self,
current_version: str,
gitea_url: str = "",
owner: str = "",
repo: str = "",
timeout: int = 10
):
"""
Initialize the update checker.
Args:
current_version: Current application version (e.g., "1.3.1")
gitea_url: Base URL of the Gitea server (e.g., "https://git.example.com")
owner: Repository owner/organization name
repo: Repository name
timeout: Request timeout in seconds
"""
self.current_version = current_version
self.gitea_url = gitea_url.rstrip('/') if gitea_url else ""
self.owner = owner
self.repo = repo
self.timeout = timeout
def is_configured(self) -> bool:
"""Check if update checking is properly configured."""
return bool(self.gitea_url and self.owner and self.repo)
def check_for_update(self) -> tuple[Optional[ReleaseInfo], Optional[str]]:
"""
Check for updates synchronously.
Returns:
Tuple of (ReleaseInfo or None, error_message or None)
- If update available: (ReleaseInfo, None)
- If no update: (None, None)
- If error: (None, error_message)
"""
if not self.is_configured():
return None, "Update checking not configured"
try:
# Query Gitea API for latest release
api_url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/releases/latest"
response = requests.get(api_url, timeout=self.timeout)
if response.status_code == 404:
return None, "No releases found"
response.raise_for_status()
data = response.json()
# Parse version from tag name (strip 'v' prefix if present)
tag_name = data.get('tag_name', '')
release_version = tag_name.lstrip('v')
if not release_version:
return None, "Invalid release tag"
# Compare versions using packaging library
try:
current_ver = version.parse(self.current_version)
release_ver = version.parse(release_version)
except version.InvalidVersion as e:
return None, f"Invalid version format: {e}"
# Check if release is newer
if release_ver > current_ver:
release_info = ReleaseInfo(
version=release_version,
tag_name=tag_name,
release_notes=data.get('body', ''),
download_url=data.get('html_url', '')
)
return release_info, None
else:
return None, None # No update available
except requests.exceptions.Timeout:
return None, "Connection timed out"
except requests.exceptions.ConnectionError:
return None, "Could not connect to server"
except requests.exceptions.RequestException as e:
return None, f"Request failed: {e}"
except Exception as e:
return None, f"Error checking for updates: {e}"
def check_for_update_async(
self,
callback: Callable[[Optional[ReleaseInfo], Optional[str]], None]
) -> threading.Thread:
"""
Check for updates asynchronously in a background thread.
Args:
callback: Function to call with (ReleaseInfo or None, error_message or None)
Returns:
The started thread
"""
def worker():
result, error = self.check_for_update()
callback(result, error)
thread = threading.Thread(target=worker, daemon=True)
thread.start()
return thread

View File

@@ -73,3 +73,12 @@ remote_processing:
server_url: "" # WebSocket URL of remote transcription service (e.g., ws://your-server:8765/ws/transcribe) server_url: "" # WebSocket URL of remote transcription service (e.g., ws://your-server:8765/ws/transcribe)
api_key: "" # API key for authentication api_key: "" # API key for authentication
fallback_to_local: true # Fall back to local processing if remote fails fallback_to_local: true # Fall back to local processing if remote fails
updates:
auto_check: true # Check for updates on startup
gitea_url: "https://repo.anhonesthost.net" # Base URL of Gitea server
owner: "streamer-tools" # Repository owner/organization name
repo: "local-transcription" # Repository name
skipped_versions: [] # List of versions the user chose to skip
last_check: "" # ISO timestamp of last update check
check_interval_hours: 24 # Hours between automatic update checks

View File

@@ -2,10 +2,13 @@
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QFileDialog, QMessageBox QPushButton, QLabel, QFileDialog, QMessageBox,
QDialog, QTextEdit, QCheckBox
) )
from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QFont from PySide6.QtGui import QFont
import webbrowser
from datetime import datetime
from pathlib import Path from pathlib import Path
import sys import sys
@@ -66,6 +69,90 @@ class EngineStartThread(QThread):
self.finished.emit(False, f"Error initializing engine: {e}") self.finished.emit(False, f"Error initializing engine: {e}")
class UpdateDialog(QDialog):
"""Dialog showing available update information."""
def __init__(self, parent, current_version: str, release_info):
"""
Initialize the update dialog.
Args:
parent: Parent window
current_version: Current application version
release_info: ReleaseInfo object with update details
"""
super().__init__(parent)
self.release_info = release_info
self.skip_version = False
self.setWindowTitle("Update Available")
self.setModal(True)
self.setMinimumSize(500, 400)
self.resize(550, 450)
layout = QVBoxLayout()
layout.setSpacing(15)
layout.setContentsMargins(20, 20, 20, 20)
self.setLayout(layout)
# Title
title_label = QLabel("Update Available!")
title_font = QFont()
title_font.setPointSize(16)
title_font.setBold(True)
title_label.setFont(title_font)
layout.addWidget(title_label)
# Version info
version_label = QLabel(f"Current: {current_version} \u2192 New: {release_info.version}")
version_font = QFont()
version_font.setPointSize(12)
version_label.setFont(version_font)
layout.addWidget(version_label)
# Release notes
notes_label = QLabel("Release Notes:")
notes_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
layout.addWidget(notes_label)
self.notes_text = QTextEdit()
self.notes_text.setReadOnly(True)
self.notes_text.setPlainText(release_info.release_notes or "No release notes available.")
layout.addWidget(self.notes_text)
# Skip version checkbox
self.skip_checkbox = QCheckBox(f"Skip version {release_info.version}")
self.skip_checkbox.setToolTip("Don't show this update again")
layout.addWidget(self.skip_checkbox)
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
self.later_button = QPushButton("Remind Me Later")
self.later_button.clicked.connect(self._on_later)
button_layout.addWidget(self.later_button)
self.download_button = QPushButton("Download Update")
self.download_button.setStyleSheet("background-color: #2ecc71; color: white; font-weight: bold;")
self.download_button.clicked.connect(self._on_download)
button_layout.addWidget(self.download_button)
layout.addLayout(button_layout)
def _on_later(self):
"""Handle 'Remind Me Later' button click."""
self.skip_version = self.skip_checkbox.isChecked()
self.reject()
def _on_download(self):
"""Handle 'Download Update' button click."""
self.skip_version = self.skip_checkbox.isChecked()
if self.release_info.download_url:
webbrowser.open(self.release_info.download_url)
self.accept()
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
"""Main application window using PySide6.""" """Main application window using PySide6."""
@@ -136,6 +223,9 @@ class MainWindow(QMainWindow):
# Initialize components (in background) # Initialize components (in background)
self._initialize_components() self._initialize_components()
# Schedule update check 3 seconds after startup (non-blocking)
QTimer.singleShot(3000, self._startup_update_check)
def _update_splash(self, message: str): def _update_splash(self, message: str):
"""Update splash screen message if it exists.""" """Update splash screen message if it exists."""
if self.splash_screen: if self.splash_screen:
@@ -836,3 +926,98 @@ class MainWindow(QMainWindow):
self.engine_start_thread.wait() self.engine_start_thread.wait()
event.accept() event.accept()
def _startup_update_check(self):
"""Check for updates on startup (called via QTimer)."""
# Only check if auto_check is enabled
if not self.config.get('updates.auto_check', True):
return
# Check if enough time has passed since last check
last_check_str = self.config.get('updates.last_check', '')
check_interval = self.config.get('updates.check_interval_hours', 24)
if last_check_str:
try:
last_check = datetime.fromisoformat(last_check_str)
hours_since_check = (datetime.now() - last_check).total_seconds() / 3600
if hours_since_check < check_interval:
print(f"Skipping update check - last checked {hours_since_check:.1f} hours ago")
return
except (ValueError, TypeError):
pass # Invalid date format, proceed with check
# Perform async update check
self._check_for_updates(show_no_update_message=False)
def _check_for_updates(self, show_no_update_message: bool = True):
"""
Check for updates.
Args:
show_no_update_message: Whether to show a message if no update is available
"""
from client.update_checker import UpdateChecker
gitea_url = self.config.get('updates.gitea_url', 'https://repo.anhonesthost.net')
owner = self.config.get('updates.owner', 'streamer-tools')
repo = self.config.get('updates.repo', 'local-transcription')
if not gitea_url or not owner or not repo:
if show_no_update_message:
QMessageBox.warning(self, "Update Check", "Update checking is not configured.")
return
checker = UpdateChecker(
current_version=__version__,
gitea_url=gitea_url,
owner=owner,
repo=repo
)
def on_update_check_complete(release_info, error):
# Update last check time
self.config.set('updates.last_check', datetime.now().isoformat())
if error:
print(f"Update check failed: {error}")
if show_no_update_message:
QMessageBox.warning(self, "Update Check Failed", f"Could not check for updates:\n{error}")
return
if release_info:
# Check if this version is skipped
skipped_versions = self.config.get('updates.skipped_versions', [])
if release_info.version in skipped_versions:
print(f"Skipping update notification for version {release_info.version} (user skipped)")
return
# Show update dialog on main thread
QTimer.singleShot(0, lambda: self._show_update_dialog(release_info))
else:
if show_no_update_message:
QMessageBox.information(
self,
"No Updates",
f"You are running the latest version ({__version__})."
)
# Run check in background thread
checker.check_for_update_async(on_update_check_complete)
def _show_update_dialog(self, release_info):
"""
Show the update dialog.
Args:
release_info: ReleaseInfo object with update details
"""
dialog = UpdateDialog(self, __version__, release_info)
dialog.exec()
# If user chose to skip this version, save it
if dialog.skip_version:
skipped_versions = self.config.get('updates.skipped_versions', [])
if release_info.version not in skipped_versions:
skipped_versions.append(release_info.version)
self.config.set('updates.skipped_versions', skipped_versions)

View File

@@ -528,6 +528,27 @@ class SettingsDialog(QDialog):
remote_group.setLayout(remote_layout) remote_group.setLayout(remote_layout)
content_layout.addWidget(remote_group) content_layout.addWidget(remote_group)
# Updates Group
updates_group = QGroupBox("Software Updates")
updates_layout = QFormLayout()
updates_layout.setSpacing(10)
self.update_auto_check = QCheckBox("Enable")
self.update_auto_check.setToolTip(
"Automatically check for updates when the application starts"
)
updates_layout.addRow("Check on Startup:", self.update_auto_check)
self.check_updates_button = QPushButton("Check for Updates Now")
self.check_updates_button.setToolTip(
"Manually check for available updates"
)
self.check_updates_button.clicked.connect(self._check_for_updates_now)
updates_layout.addRow("", self.check_updates_button)
updates_group.setLayout(updates_layout)
content_layout.addWidget(updates_group)
# Add stretch to push everything to the top # Add stretch to push everything to the top
content_layout.addStretch() content_layout.addStretch()
@@ -779,6 +800,9 @@ class SettingsDialog(QDialog):
self.remote_api_key_input.setText(self.config.get('remote_processing.api_key', '')) self.remote_api_key_input.setText(self.config.get('remote_processing.api_key', ''))
self.remote_fallback_check.setChecked(self.config.get('remote_processing.fallback_to_local', True)) self.remote_fallback_check.setChecked(self.config.get('remote_processing.fallback_to_local', True))
# Update settings
self.update_auto_check.setChecked(self.config.get('updates.auto_check', True))
def _save_settings(self): def _save_settings(self):
"""Save settings to config.""" """Save settings to config."""
try: try:
@@ -851,6 +875,9 @@ class SettingsDialog(QDialog):
self.config.set('remote_processing.api_key', self.remote_api_key_input.text()) self.config.set('remote_processing.api_key', self.remote_api_key_input.text())
self.config.set('remote_processing.fallback_to_local', self.remote_fallback_check.isChecked()) self.config.set('remote_processing.fallback_to_local', self.remote_fallback_check.isChecked())
# Update settings
self.config.set('updates.auto_check', self.update_auto_check.isChecked())
# Call save callback (which will show the success message) # Call save callback (which will show the success message)
if self.on_save: if self.on_save:
self.on_save() self.on_save()
@@ -864,3 +891,54 @@ class SettingsDialog(QDialog):
QMessageBox.critical(self, "Invalid Input", f"Please check your input values:\n{e}") QMessageBox.critical(self, "Invalid Input", f"Please check your input values:\n{e}")
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save settings:\n{e}") QMessageBox.critical(self, "Error", f"Failed to save settings:\n{e}")
def _check_for_updates_now(self):
"""Manually check for updates."""
from version import __version__
from client.update_checker import UpdateChecker
# Get settings from config (hardcoded defaults)
gitea_url = self.config.get('updates.gitea_url', 'https://repo.anhonesthost.net')
owner = self.config.get('updates.owner', 'streamer-tools')
repo = self.config.get('updates.repo', 'local-transcription')
# Disable button during check
self.check_updates_button.setEnabled(False)
self.check_updates_button.setText("Checking...")
try:
checker = UpdateChecker(
current_version=__version__,
gitea_url=gitea_url,
owner=owner,
repo=repo
)
release_info, error = checker.check_for_update()
if error:
QMessageBox.warning(self, "Update Check Failed", f"Could not check for updates:\n{error}")
elif release_info:
# Update available
msg = QMessageBox(self)
msg.setWindowTitle("Update Available")
msg.setIcon(QMessageBox.Information)
msg.setText(f"A new version is available!\n\nCurrent: {__version__}\nNew: {release_info.version}")
if release_info.release_notes:
msg.setDetailedText(release_info.release_notes)
msg.setStandardButtons(QMessageBox.Ok)
msg.exec()
else:
QMessageBox.information(
self,
"No Updates",
f"You are running the latest version ({__version__})."
)
except Exception as e:
QMessageBox.critical(self, "Error", f"Error checking for updates:\n{e}")
finally:
# Re-enable button
self.check_updates_button.setEnabled(True)
self.check_updates_button.setText("Check for Updates Now")

View File

@@ -26,6 +26,8 @@ dependencies = [
"websockets>=12.0", "websockets>=12.0",
# Server sync client # Server sync client
"requests>=2.31.0", "requests>=2.31.0",
# Version comparison for updates
"packaging>=21.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -1,9 +1,18 @@
"""Version information for Local Transcription.""" """Version information for Local Transcription."""
__version__ = "1.3.1" __version__ = "1.4.0"
__version_info__ = (1, 3, 1) __version_info__ = (1, 4, 0)
# Version history: # Version history:
# 1.4.0 - Auto-update feature:
# - Automatic update checking on startup (configurable)
# - Shows update dialog with release notes when new version available
# - Opens browser to Gitea release page for download
# - Skip version option to dismiss specific updates
# - Manual "Check for Updates Now" button in settings
#
# 1.3.1 - User-configurable colors for transcription display
#
# 1.0.0 - Initial release with: # 1.0.0 - Initial release with:
# - Real-time speech-to-text transcription using Whisper models # - Real-time speech-to-text transcription using Whisper models
# - Local web display for OBS browser source integration # - Local web display for OBS browser source integration