From 795a15cb8bf0a8f6f1a3adc40ca06d003eec9016 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Mar 2026 11:37:09 +0300 Subject: [PATCH] feat: add update-available notification system - 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) --- media_server/config.py | 11 ++ media_server/main.py | 16 +++ media_server/routes/health.py | 16 ++- .../services/gitea_release_provider.py | 77 +++++++++++ media_server/services/release_provider.py | 29 +++++ media_server/services/update_checker.py | 120 ++++++++++++++++++ media_server/static/css/styles.css | 53 ++++++++ media_server/static/index.html | 7 + media_server/static/js/core.js | 23 ++++ media_server/static/js/websocket.js | 4 +- media_server/static/locales/en.json | 4 +- media_server/static/locales/ru.json | 4 +- 12 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 media_server/services/gitea_release_provider.py create mode 100644 media_server/services/release_provider.py create mode 100644 media_server/services/update_checker.py diff --git a/media_server/config.py b/media_server/config.py index f7a0a87..85d5f15 100644 --- a/media_server/config.py +++ b/media_server/config.py @@ -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.""" diff --git a/media_server/main.py b/media_server/main.py index c7f0f88..55f7190 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -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: diff --git a/media_server/routes/health.py b/media_server/routes/health.py index f61c2db..1897dea 100644 --- a/media_server/routes/health.py +++ b/media_server/routes/health.py @@ -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 diff --git a/media_server/services/gitea_release_provider.py b/media_server/services/gitea_release_provider.py new file mode 100644 index 0000000..0ea71ba --- /dev/null +++ b/media_server/services/gitea_release_provider.py @@ -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 diff --git a/media_server/services/release_provider.py b/media_server/services/release_provider.py new file mode 100644 index 0000000..485025e --- /dev/null +++ b/media_server/services/release_provider.py @@ -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. + """ + ... diff --git a/media_server/services/update_checker.py b/media_server/services/update_checker.py new file mode 100644 index 0000000..5fa7111 --- /dev/null +++ b/media_server/services/update_checker.py @@ -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 diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index e33b109..f21a2f3 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -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; diff --git a/media_server/static/index.html b/media_server/static/index.html index 6631545..7a99352 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -114,6 +114,13 @@ + + +