diff --git a/client/config.py b/client/config.py index d964f8f..73f4e20 100644 --- a/client/config.py +++ b/client/config.py @@ -133,6 +133,15 @@ class Config: 'max_lines': 100, 'font_size': 12, '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 } } diff --git a/client/update_checker.py b/client/update_checker.py new file mode 100644 index 0000000..62c3609 --- /dev/null +++ b/client/update_checker.py @@ -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 diff --git a/config/default_config.yaml b/config/default_config.yaml index 66ede0d..135daa3 100644 --- a/config/default_config.yaml +++ b/config/default_config.yaml @@ -73,3 +73,12 @@ remote_processing: server_url: "" # WebSocket URL of remote transcription service (e.g., ws://your-server:8765/ws/transcribe) api_key: "" # API key for authentication 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 diff --git a/gui/main_window_qt.py b/gui/main_window_qt.py index e43701d..daaaaa4 100644 --- a/gui/main_window_qt.py +++ b/gui/main_window_qt.py @@ -2,10 +2,13 @@ from PySide6.QtWidgets import ( 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 +import webbrowser +from datetime import datetime from pathlib import Path import sys @@ -66,6 +69,90 @@ class EngineStartThread(QThread): 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): """Main application window using PySide6.""" @@ -136,6 +223,9 @@ class MainWindow(QMainWindow): # Initialize components (in background) 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): """Update splash screen message if it exists.""" if self.splash_screen: @@ -836,3 +926,98 @@ class MainWindow(QMainWindow): self.engine_start_thread.wait() 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) diff --git a/gui/settings_dialog_qt.py b/gui/settings_dialog_qt.py index 0b28bbc..9fc6c6b 100644 --- a/gui/settings_dialog_qt.py +++ b/gui/settings_dialog_qt.py @@ -528,6 +528,27 @@ class SettingsDialog(QDialog): remote_group.setLayout(remote_layout) 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 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_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): """Save settings to config.""" 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.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) if 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}") except Exception as 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") diff --git a/pyproject.toml b/pyproject.toml index 6eaf6a9..bd627f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ dependencies = [ "websockets>=12.0", # Server sync client "requests>=2.31.0", + # Version comparison for updates + "packaging>=21.0", ] [project.optional-dependencies] diff --git a/version.py b/version.py index 4a95f51..537e914 100644 --- a/version.py +++ b/version.py @@ -1,9 +1,18 @@ """Version information for Local Transcription.""" -__version__ = "1.3.1" -__version_info__ = (1, 3, 1) +__version__ = "1.4.0" +__version_info__ = (1, 4, 0) # 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: # - Real-time speech-to-text transcription using Whisper models # - Local web display for OBS browser source integration