- 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:
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Connection Banner -->
|
||||
<div class="connection-banner hidden" id="connectionBanner">
|
||||
<span id="connectionBannerText"></span>
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Перейти к релизу"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user