feat: add update-available notification system
Lint & Test / test (push) Successful in 10s

- Abstract ReleaseProvider protocol for platform-agnostic version checking
- GiteaReleaseProvider implementation using stdlib urllib
- UpdateChecker service with periodic background checks and WS broadcast
- Persistent dismissible banner in Web UI when a new version is detected
- Health endpoint now returns cached update info
- Configurable via update_check_enabled and update_check_interval settings
- i18n support (EN/RU)
This commit is contained in:
2026-03-25 11:37:09 +03:00
parent 1410a8d2cb
commit 795a15cb8b
12 changed files with 357 additions and 7 deletions
+11
View File
@@ -159,6 +159,17 @@ class Settings(BaseSettings):
description="Loopback audio device name for visualizer (None = auto-detect)",
)
# Update checker
update_check_enabled: bool = Field(
default=True,
description="Check for new versions on startup and periodically",
)
update_check_interval: int = Field(
default=21600,
description="Update check interval in seconds (default: 6 hours)",
ge=600,
)
@classmethod
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
"""Load settings from a YAML configuration file."""
+16
View File
@@ -74,6 +74,18 @@ async def lifespan(app: FastAPI):
await ws_manager.start_status_monitor(controller.get_status)
logger.info("WebSocket status monitor started")
# Start update checker
update_checker = None
if settings.update_check_enabled:
from .services.gitea_release_provider import GiteaReleaseProvider
from .services.update_checker import UpdateChecker
provider = GiteaReleaseProvider()
update_checker = UpdateChecker(provider, __version__)
await update_checker.start(settings.update_check_interval)
# Store globally so health endpoint can access cached result
app.state.update_checker = update_checker
# Register audio visualizer (capture starts on-demand when clients subscribe)
analyzer = None
if settings.visualizer_enabled:
@@ -92,6 +104,10 @@ async def lifespan(app: FastAPI):
yield
# Stop update checker
if update_checker is not None:
await update_checker.stop()
# Stop audio visualizer
await ws_manager.stop_audio_monitor()
if analyzer and analyzer.running:
+12 -4
View File
@@ -3,23 +3,31 @@
import platform
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Request
from .. import __version__
from ..auth import auth_enabled
router = APIRouter(prefix="/api", tags=["health"])
@router.get("/health")
async def health_check() -> dict[str, Any]:
async def health_check(request: Request) -> dict[str, Any]:
"""Health check endpoint - no authentication required.
Returns:
Health status and server information
"""
return {
result: dict[str, Any] = {
"status": "healthy",
"platform": platform.system(),
"version": "1.0.0",
"version": __version__,
"auth_required": auth_enabled(),
}
# Include cached update info if available
checker = getattr(request.app.state, "update_checker", None)
if checker is not None and checker.cached_update is not None:
result["update_available"] = checker.cached_update
return result
@@ -0,0 +1,77 @@
"""Gitea release provider implementation."""
import json
import logging
import urllib.error
import urllib.request
from typing import Optional
from .release_provider import ReleaseInfo, ReleaseProvider
logger = logging.getLogger(__name__)
# Default repository coordinates
_DEFAULT_BASE_URL = "https://git.dolgolyov-family.by"
_DEFAULT_OWNER = "alexei.dolgolyov"
_DEFAULT_REPO = "media-player-server"
class GiteaReleaseProvider(ReleaseProvider):
"""Fetches the latest release from a Gitea repository."""
def __init__(
self,
base_url: str = _DEFAULT_BASE_URL,
owner: str = _DEFAULT_OWNER,
repo: str = _DEFAULT_REPO,
timeout: float = 10.0,
) -> None:
self._api_url = f"{base_url}/api/v1/repos/{owner}/{repo}/releases"
self._release_page_url = f"{base_url}/{owner}/{repo}/releases/tag"
self._timeout = timeout
async def get_latest_release(self) -> Optional[ReleaseInfo]:
"""Fetch the latest stable release from Gitea API.
Returns:
ReleaseInfo for the latest non-prerelease, or None on failure.
"""
import asyncio
try:
data = await asyncio.to_thread(self._fetch_releases)
except Exception as e:
logger.warning("Failed to check for updates: %s", e)
return None
if not data:
return None
# Find the first non-prerelease, non-draft release
for release in data:
if release.get("draft") or release.get("prerelease"):
continue
tag = release.get("tag_name", "")
version = tag.lstrip("v")
if not version:
continue
return ReleaseInfo(
version=version,
url=f"{self._release_page_url}/{tag}",
prerelease=False,
)
logger.debug("No stable releases found")
return None
def _fetch_releases(self) -> list[dict]:
"""Synchronous HTTP fetch of releases (run in thread)."""
url = f"{self._api_url}?limit=5"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
return json.loads(resp.read().decode())
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError) as e:
raise RuntimeError(f"Gitea API request failed: {e}") from e
+29
View File
@@ -0,0 +1,29 @@
"""Abstract release provider interface for version checking."""
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class ReleaseInfo:
"""Version-provider-agnostic release metadata."""
version: str # e.g. "1.1.0" (no "v" prefix)
url: str # release page URL
prerelease: bool
class ReleaseProvider(Protocol):
"""Abstract interface for fetching the latest release.
Implement this protocol to support different hosting platforms
(Gitea, GitHub, GitLab, etc.).
"""
async def get_latest_release(self) -> ReleaseInfo | None:
"""Fetch the latest stable release.
Returns:
ReleaseInfo if a release was found, None on failure.
"""
...
+120
View File
@@ -0,0 +1,120 @@
"""Provider-agnostic update checker service."""
import asyncio
import logging
from typing import Any, Optional
from .release_provider import ReleaseProvider
from .websocket_manager import ws_manager
logger = logging.getLogger(__name__)
def _parse_version(version: str) -> tuple[int, ...]:
"""Parse a version string into a comparable tuple.
Handles versions like "1.0.0", "1.2.3", ignoring non-numeric suffixes.
"""
parts: list[int] = []
for part in version.split("."):
digits = ""
for ch in part:
if ch.isdigit():
digits += ch
else:
break
if digits:
parts.append(int(digits))
return tuple(parts)
class UpdateChecker:
"""Periodically checks for new releases using a ReleaseProvider."""
def __init__(self, provider: ReleaseProvider, current_version: str) -> None:
self._provider = provider
self._current_version = current_version
self._current_parsed = _parse_version(current_version)
self._task: Optional[asyncio.Task] = None
self._cached_update: Optional[dict[str, Any]] = None
@property
def cached_update(self) -> Optional[dict[str, Any]]:
"""Return the cached update info, or None if up-to-date."""
return self._cached_update
async def check_for_update(self) -> Optional[dict[str, Any]]:
"""Check for a newer release.
Returns:
Dict with current/latest/url if an update exists, None otherwise.
"""
release = await self._provider.get_latest_release()
if release is None:
return None
latest_parsed = _parse_version(release.version)
if latest_parsed <= self._current_parsed:
return None
return {
"current": self._current_version,
"latest": release.version,
"url": release.url,
}
async def start(self, interval: int) -> None:
"""Start periodic update checking.
Checks immediately on start, then every `interval` seconds.
"""
if self._task is not None:
return
self._task = asyncio.create_task(self._check_loop(interval))
logger.info("Update checker started (interval: %ds)", interval)
async def stop(self) -> None:
"""Stop periodic update checking."""
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info("Update checker stopped")
async def _check_loop(self, interval: int) -> None:
"""Background loop that checks for updates periodically."""
# Initial check with a small delay to let the server finish starting
await asyncio.sleep(5)
while True:
try:
update = await self.check_for_update()
if update and update != self._cached_update:
self._cached_update = update
logger.info(
"New version available: %s%s (%s)",
update["current"],
update["latest"],
update["url"],
)
await ws_manager.broadcast(
{"type": "update_available", "data": update}
)
elif update is None and self._cached_update is not None:
# Version was updated (or release removed) — clear cache
self._cached_update = None
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("Update check failed: %s", e)
try:
await asyncio.sleep(interval)
except asyncio.CancelledError:
break
+53
View File
@@ -3490,6 +3490,59 @@ footer .separator {
}
}
/* Update Banner */
.update-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 10px 16px;
background: var(--accent);
color: #fff;
font-size: 0.85rem;
font-weight: 500;
text-align: center;
transition: transform 0.3s ease;
}
.update-banner:not(.hidden) {
animation: bannerSlideIn 0.4s ease-out;
}
.update-banner.hidden {
transform: translateY(-100%);
pointer-events: none;
}
.update-banner a {
color: #fff;
text-decoration: underline;
font-weight: 600;
}
.update-banner a:hover {
opacity: 0.85;
}
.update-banner-close {
background: none;
color: #fff;
font-size: 1.2rem;
padding: 0 4px;
opacity: 0.7;
cursor: pointer;
line-height: 1;
}
.update-banner-close:hover {
opacity: 1;
}
/* Connection Banner */
.connection-banner {
position: fixed;
+7
View File
@@ -114,6 +114,13 @@
</div>
</header>
<!-- Update Banner -->
<div class="update-banner hidden" id="updateBanner">
<span id="updateBannerText"></span>
<a id="updateBannerLink" href="#" target="_blank" rel="noopener noreferrer" data-i18n="update.view_release">View Release</a>
<button class="update-banner-close" id="updateBannerClose">&times;</button>
</div>
<!-- Connection Banner -->
<div class="connection-banner hidden" id="connectionBanner">
<span id="connectionBannerText"></span>
+23
View File
@@ -318,12 +318,35 @@ export async function fetchVersion() {
if (data.version) {
label.textContent = `v${data.version}`;
}
if (data.update_available) {
showUpdateBanner(data.update_available);
}
}
} catch (error) {
console.error('Error fetching version:', error);
}
}
export function showUpdateBanner(update) {
const dismissed = sessionStorage.getItem('update_dismissed');
if (dismissed === update.latest) return;
const banner = document.getElementById('updateBanner');
const text = document.getElementById('updateBannerText');
const link = document.getElementById('updateBannerLink');
const closeBtn = document.getElementById('updateBannerClose');
text.textContent = t('update.available', { version: update.latest });
link.href = update.url;
link.textContent = t('update.view_release');
banner.classList.remove('hidden');
closeBtn.onclick = () => {
banner.classList.add('hidden');
sessionStorage.setItem('update_dismissed', update.latest);
};
}
// ============================================================
// Shared Utilities
// ============================================================
+3 -1
View File
@@ -6,7 +6,7 @@ import {
dom, t, showToast, setWs,
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
authRequired,
authRequired, showUpdateBanner,
} from './core.js';
import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
@@ -100,6 +100,8 @@ export function connectWebSocket(token) {
loadHeaderLinks();
loadLinksTable();
displayQuickAccess();
} else if (msg.type === 'update_available') {
showUpdateBanner(msg.data);
} else if (msg.type === 'audio_data') {
setFrequencyData(msg.data);
} else if (msg.type === 'error') {
+3 -1
View File
@@ -224,5 +224,7 @@
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"footer.created_by": "Created by",
"footer.source_code": "Source Code"
"footer.source_code": "Source Code",
"update.available": "Update available: v{version}",
"update.view_release": "View Release"
}
+3 -1
View File
@@ -224,5 +224,7 @@
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"footer.created_by": "Создано",
"footer.source_code": "Исходный код"
"footer.source_code": "Исходный код",
"update.available": "Доступно обновление: v{version}",
"update.view_release": "Перейти к релизу"
}