diff --git a/Dockerfile b/Dockerfile index 52fe058..82aa5d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,8 @@ LABEL org.opencontainers.image.title="MusicSeerr" \ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PORT=8688 \ - COMMIT_TAG=${COMMIT_TAG} + COMMIT_TAG=${COMMIT_TAG} \ + BUILD_DATE=${BUILD_DATE} WORKDIR /app diff --git a/backend/api/v1/routes/version.py b/backend/api/v1/routes/version.py new file mode 100644 index 0000000..b0a9275 --- /dev/null +++ b/backend/api/v1/routes/version.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter, Depends + +from api.v1.schemas.version import GitHubRelease, UpdateCheckResponse, VersionInfo +from core.dependencies import get_version_service +from infrastructure.msgspec_fastapi import MsgSpecRoute +from services.version_service import VersionService + +router = APIRouter(route_class=MsgSpecRoute, prefix="/version", tags=["version"]) + + +@router.get("", response_model=VersionInfo) +async def get_version( + version_service: VersionService = Depends(get_version_service), +): + return version_service.get_current_version() + + +@router.get("/check-update", response_model=UpdateCheckResponse) +async def check_update( + version_service: VersionService = Depends(get_version_service), +): + return await version_service.check_for_updates() + + +@router.get("/releases", response_model=list[GitHubRelease]) +async def get_releases( + version_service: VersionService = Depends(get_version_service), +): + return await version_service.get_release_history() diff --git a/backend/api/v1/schemas/version.py b/backend/api/v1/schemas/version.py new file mode 100644 index 0000000..525b032 --- /dev/null +++ b/backend/api/v1/schemas/version.py @@ -0,0 +1,23 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class VersionInfo(AppStruct): + version: str + build_date: str | None = None + + +class GitHubRelease(AppStruct): + tag_name: str + published_at: str + html_url: str + name: str | None = None + body: str | None = None + prerelease: bool = False + + +class UpdateCheckResponse(AppStruct): + current_version: str + latest_version: str | None = None + update_available: bool = False + comparison_failed: bool = False + latest_release: GitHubRelease | None = None diff --git a/backend/core/dependencies/__init__.py b/backend/core/dependencies/__init__.py index cf0a18a..33a79dd 100644 --- a/backend/core/dependencies/__init__.py +++ b/backend/core/dependencies/__init__.py @@ -35,6 +35,7 @@ from .repo_providers import ( # noqa: F401 get_lastfm_repository, get_playlist_repository, get_request_history_store, + get_github_repository, ) from .service_providers import ( # noqa: F401 @@ -68,6 +69,7 @@ from .service_providers import ( # noqa: F401 get_navidrome_playback_service, get_plex_library_service, get_plex_playback_service, + get_version_service, ) from .type_aliases import ( # noqa: F401 @@ -116,6 +118,8 @@ from .type_aliases import ( # noqa: F401 PlexLibraryServiceDep, PlexPlaybackServiceDep, CacheStatusServiceDep, + GitHubRepositoryDep, + VersionServiceDep, ) from .cleanup import ( # noqa: F401 diff --git a/backend/core/dependencies/repo_providers.py b/backend/core/dependencies/repo_providers.py index a43611d..c9788f4 100644 --- a/backend/core/dependencies/repo_providers.py +++ b/backend/core/dependencies/repo_providers.py @@ -265,3 +265,12 @@ def get_coverart_repository() -> "CoverArtRepository": cover_memory_cache_max_bytes=advanced.cover_memory_cache_max_size_mb * 1024 * 1024, cover_non_monitored_ttl_seconds=advanced.cache_ttl_recently_viewed_bytes, ) + + +@singleton +def get_github_repository() -> "GitHubRepository": + from repositories.github_repository import GitHubRepository + + cache = get_cache() + http_client = _get_configured_http_client() + return GitHubRepository(http_client, cache) diff --git a/backend/core/dependencies/service_providers.py b/backend/core/dependencies/service_providers.py index 03579cf..a71fa9c 100644 --- a/backend/core/dependencies/service_providers.py +++ b/backend/core/dependencies/service_providers.py @@ -43,6 +43,7 @@ from .repo_providers import ( get_lastfm_repository, get_playlist_repository, get_request_history_store, + get_github_repository, ) logger = logging.getLogger(__name__) @@ -619,3 +620,11 @@ def get_plex_playback_service() -> "PlexPlaybackService": plex_repo = get_plex_repository() cache = get_cache() return PlexPlaybackService(plex_repo, cache) + + +@singleton +def get_version_service() -> "VersionService": + from services.version_service import VersionService + + github_repo = get_github_repository() + return VersionService(github_repo) diff --git a/backend/core/dependencies/type_aliases.py b/backend/core/dependencies/type_aliases.py index ae05caf..0ad4cc3 100644 --- a/backend/core/dependencies/type_aliases.py +++ b/backend/core/dependencies/type_aliases.py @@ -22,6 +22,7 @@ from repositories.lastfm_repository import LastFmRepository from repositories.playlist_repository import PlaylistRepository from repositories.navidrome_repository import NavidromeRepository from repositories.plex_repository import PlexRepository +from repositories.github_repository import GitHubRepository from services.preferences_service import PreferencesService from services.search_service import SearchService from services.search_enrichment_service import SearchEnrichmentService @@ -51,6 +52,7 @@ from services.playlist_service import PlaylistService from services.lastfm_auth_service import LastFmAuthService from services.scrobble_service import ScrobbleService from services.cache_status_service import CacheStatusService +from services.version_service import VersionService from .cache_providers import ( get_cache, @@ -72,6 +74,7 @@ from .repo_providers import ( get_request_history_store, get_navidrome_repository, get_plex_repository, + get_github_repository, ) from .service_providers import ( get_search_service, @@ -101,6 +104,7 @@ from .service_providers import ( get_navidrome_playback_service, get_plex_library_service, get_plex_playback_service, + get_version_service, ) @@ -149,3 +153,5 @@ PlexRepositoryDep = Annotated[PlexRepository, Depends(get_plex_repository)] PlexLibraryServiceDep = Annotated[PlexLibraryService, Depends(get_plex_library_service)] PlexPlaybackServiceDep = Annotated[PlexPlaybackService, Depends(get_plex_playback_service)] CacheStatusServiceDep = Annotated[CacheStatusService, Depends(get_cache_status_service)] +GitHubRepositoryDep = Annotated[GitHubRepository, Depends(get_github_repository)] +VersionServiceDep = Annotated[VersionService, Depends(get_version_service)] diff --git a/backend/infrastructure/cache/cache_keys.py b/backend/infrastructure/cache/cache_keys.py index d9a6234..7dacabe 100644 --- a/backend/infrastructure/cache/cache_keys.py +++ b/backend/infrastructure/cache/cache_keys.py @@ -57,6 +57,8 @@ WIKIPEDIA_PREFIX = "wikipedia:extract:" PREFERENCES_PREFIX = "preferences:" +GITHUB_RELEASES_PREFIX = "github:releases:" + AUDIODB_PREFIX = "audiodb_" diff --git a/backend/main.py b/backend/main.py index ff8e31d..1cf852d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -50,6 +50,7 @@ from api.v1.routes import lastfm as lastfm_routes from api.v1.routes import scrobble as scrobble_routes from api.v1.routes import plex_library as plex_library_routes from api.v1.routes import plex_auth as plex_auth_routes +from api.v1.routes import version as version_routes logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) @@ -339,6 +340,7 @@ v1_router.include_router(lastfm_routes.router) v1_router.include_router(scrobble_routes.router) v1_router.include_router(profile.router) v1_router.include_router(playlists.router) +v1_router.include_router(version_routes.router) app.include_router(v1_router) mount_frontend(app) diff --git a/backend/repositories/github_repository.py b/backend/repositories/github_repository.py new file mode 100644 index 0000000..da64c77 --- /dev/null +++ b/backend/repositories/github_repository.py @@ -0,0 +1,83 @@ +import logging + +import httpx +import msgspec + +from api.v1.schemas.version import GitHubRelease +from infrastructure.cache.cache_keys import GITHUB_RELEASES_PREFIX +from infrastructure.cache.memory_cache import CacheInterface + +logger = logging.getLogger(__name__) + +GITHUB_API_URL = "https://api.github.com/repos/HabiRabbu/Musicseerr/releases" +GITHUB_RELEASES_CACHE_KEY = f"{GITHUB_RELEASES_PREFIX}all" +GITHUB_RELEASES_CACHE_TTL = 3600 + + +class _GitHubReleaseRaw(msgspec.Struct): + """Raw GitHub API response struct for decoding.""" + + tag_name: str + published_at: str + html_url: str + name: str | None = None + body: str | None = None + prerelease: bool = False + draft: bool = False + + +class GitHubRepository: + def __init__(self, http_client: httpx.AsyncClient, cache: CacheInterface): + self._client = http_client + self._cache = cache + + async def fetch_releases(self) -> list[GitHubRelease]: + """Fetch all non-draft releases from GitHub, with 1hr server-side cache.""" + cached = await self._cache.get(GITHUB_RELEASES_CACHE_KEY) + if cached is not None: + return cached + + try: + response = await self._client.get( + GITHUB_API_URL, + headers={"Accept": "application/vnd.github+json"}, + timeout=10.0, + ) + if response.status_code != 200: + logger.warning("GitHub releases API returned %s", response.status_code) + return [] + + raw_releases = msgspec.json.decode( + response.content, type=list[_GitHubReleaseRaw] + ) + releases = [ + GitHubRelease( + tag_name=r.tag_name, + name=r.name or r.tag_name, + body=r.body or "", + published_at=r.published_at, + html_url=r.html_url, + prerelease=r.prerelease, + ) + for r in raw_releases + if not r.draft + ] + + await self._cache.set( + GITHUB_RELEASES_CACHE_KEY, + releases, + ttl_seconds=GITHUB_RELEASES_CACHE_TTL, + ) + return releases + + except (httpx.HTTPError, msgspec.DecodeError) as e: + logger.error("Failed to fetch GitHub releases: %s", e) + return [] + + async def fetch_latest_release(self) -> GitHubRelease | None: + """Get the latest non-prerelease release.""" + releases = await self.fetch_releases() + for release in releases: + if not release.prerelease: + return release + return None diff --git a/backend/requirements.txt b/backend/requirements.txt index dd927b2..3383928 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ python-multipart==0.0.20 pydantic==2.12.0 pydantic-settings==2.3.0 uvicorn[standard]==0.37.0 +packaging>=24.0 diff --git a/backend/services/version_service.py b/backend/services/version_service.py new file mode 100644 index 0000000..d210aca --- /dev/null +++ b/backend/services/version_service.py @@ -0,0 +1,59 @@ +import logging +import os + +from packaging.version import InvalidVersion, Version + +from api.v1.schemas.version import GitHubRelease, UpdateCheckResponse, VersionInfo +from repositories.github_repository import GitHubRepository + +logger = logging.getLogger(__name__) + + +class VersionService: + def __init__(self, github_repo: GitHubRepository): + self._github_repo = github_repo + + def get_current_version(self) -> VersionInfo: + version = os.environ.get("COMMIT_TAG", "dev") + build_date = os.environ.get("BUILD_DATE") + return VersionInfo(version=version, build_date=build_date) + + async def check_for_updates(self) -> UpdateCheckResponse: + current = self.get_current_version() + latest = await self._github_repo.fetch_latest_release() + + if latest is None: + return UpdateCheckResponse(current_version=current.version) + + update_available, comparison_failed = self._is_newer( + latest.tag_name, current.version + ) + + # Dev builds: simulate update available so the full UI can be tested + is_dev = current.version in ("dev", "hosting-local") + if comparison_failed and is_dev: + update_available = True + + return UpdateCheckResponse( + current_version=current.version, + latest_version=latest.tag_name, + update_available=update_available, + comparison_failed=comparison_failed, + latest_release=latest if update_available else None, + ) + + async def get_release_history(self) -> list[GitHubRelease]: + return await self._github_repo.fetch_releases() + + @staticmethod + def _is_newer(latest_tag: str, current_tag: str) -> tuple[bool, bool]: + """Compare version tags. Returns (update_available, comparison_failed).""" + try: + latest = Version(latest_tag.lstrip("v")) + current = Version(current_tag.lstrip("v")) + return (latest > current, False) + except InvalidVersion: + logger.warning( + "Invalid version comparison: %s vs %s", latest_tag, current_tag + ) + return (False, True) diff --git a/backend/tests/test_dependencies_package.py b/backend/tests/test_dependencies_package.py index 05554bd..5287f7a 100644 --- a/backend/tests/test_dependencies_package.py +++ b/backend/tests/test_dependencies_package.py @@ -9,7 +9,7 @@ from core.dependencies._registry import _singleton_registry, clear_all_singleton class TestSingletonRegistry: def test_registry_has_expected_count(self): - assert len(_singleton_registry) == 55 + assert len(_singleton_registry) == 57 def test_all_entries_have_cache_clear(self): for fn in _singleton_registry: diff --git a/frontend/package.json b/frontend/package.json index fed6f1a..a0895a2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@sveltejs/vite-plugin-svelte": "^6.2.0", "@tailwindcss/cli": "^4.1.14", "@tailwindcss/postcss": "^4.1.14", + "@tailwindcss/typography": "^0.5.19", "@types/node": "^22", "@vitest/browser": "^3.2.4", "daisyui": "^5.3.1", @@ -51,8 +52,10 @@ "@tanstack/svelte-query": "^6.1.13", "@tanstack/svelte-query-devtools": "^6.1.13", "@tanstack/svelte-query-persist-client": "^6.1.13", + "dompurify": "^3.4.0", "idb-keyval": "^6.2.2", "lucide-svelte": "^0.575.0", + "marked": "^18.0.0", "runed": "^0.37.1" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 07d0807..84e728e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -17,12 +17,18 @@ importers: '@tanstack/svelte-query-persist-client': specifier: ^6.1.13 version: 6.1.13(@tanstack/svelte-query@6.1.13(svelte@5.55.1))(svelte@5.55.1) + dompurify: + specifier: ^3.4.0 + version: 3.4.0 idb-keyval: specifier: ^6.2.2 version: 6.2.2 lucide-svelte: specifier: ^0.575.0 version: 0.575.0(svelte@5.55.1) + marked: + specifier: ^18.0.0 + version: 18.0.0 runed: specifier: ^0.37.1 version: 0.37.1(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1) @@ -51,6 +57,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.14 version: 4.2.2 + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.2.2) '@types/node': specifier: ^22 version: 22.19.17 @@ -736,6 +745,11 @@ packages: '@tailwindcss/postcss@4.2.2': resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tanstack/query-core@5.96.2': resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==} @@ -1044,6 +1058,9 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dompurify@3.4.0: + resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + enhanced-resolve@5.20.1: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} @@ -1370,6 +1387,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@18.0.0: + resolution: {integrity: sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==} + engines: {node: '>= 20'} + hasBin: true + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1474,6 +1496,10 @@ packages: peerDependencies: postcss: ^8.4.29 + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -2233,6 +2259,11 @@ snapshots: postcss: 8.5.8 tailwindcss: 4.2.2 + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.2.2 + '@tanstack/query-core@5.96.2': {} '@tanstack/query-devtools@5.96.2': {} @@ -2556,6 +2587,10 @@ snapshots: dom-accessibility-api@0.5.16: {} + dompurify@3.4.0: + optionalDependencies: + '@types/trusted-types': 2.0.7 + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 @@ -2868,6 +2903,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@18.0.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -2946,6 +2983,11 @@ snapshots: dependencies: postcss: 8.5.8 + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 diff --git a/frontend/src/app.css b/frontend/src/app.css index 865db9e..7d8655b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,4 +1,5 @@ @import 'tailwindcss'; +@plugin "@tailwindcss/typography"; @plugin "daisyui" { themes: dark --default; } @@ -18,7 +19,7 @@ --animate-shimmer: shimmer 2s ease-in-out infinite; --animate-glow-pulse: glow-pulse 2.5s ease-in-out infinite; --animate-float: float 3s ease-in-out infinite; - --animate-fade-in-up: fade-in-up 0.5s ease-out forwards; + --animate-fade-in-up: fade-in-up 0.3s ease-out forwards; --animate-note-float: note-float 6s ease-in-out infinite; --animate-slide-in-left: slide-in-left 0.2s ease-out forwards; --animate-slide-in-right: slide-in-right 0.2s ease-out forwards; @@ -39,10 +40,10 @@ @keyframes glow-pulse { 0%, 100% { - box-shadow: 0 0 8px rgba(174, 213, 242, 0.15); + box-shadow: 0 0 8px oklch(from var(--color-primary) l c h / 0.15); } 50% { - box-shadow: 0 0 20px rgba(174, 213, 242, 0.3); + box-shadow: 0 0 20px oklch(from var(--color-primary) l c h / 0.3); } } @keyframes float { @@ -347,6 +348,12 @@ opacity: 1 !important; transform: none !important; } + .banner-enter, + .whats-new-modal .modal-box, + .animate-glow-pulse { + animation: none !important; + opacity: 1 !important; + } } :root { @@ -416,3 +423,75 @@ .now-playing-bars--sm span { width: 2px; } + +dialog.whats-new-modal::backdrop { + backdrop-filter: blur(16px) saturate(0.8); + -webkit-backdrop-filter: blur(16px) saturate(0.8); + background: rgba(0, 0, 0, 0.6); +} + +/* Release notes prose styling - ensures readable list spacing and bullet visibility */ +.release-notes-prose :is(ul, ol) { + padding-left: 1.25em; + margin-top: 0.5em; + margin-bottom: 0.5em; +} +.release-notes-prose ul { + list-style-type: disc; +} +.release-notes-prose ol { + list-style-type: decimal; +} +.release-notes-prose li { + margin-top: 0.35em; + margin-bottom: 0.35em; + padding-left: 0.25em; +} +.release-notes-prose li::marker { + color: oklch(from var(--color-accent) l c h / 0.6); +} +.release-notes-prose :is(h1, h2, h3, h4) { + margin-top: 1em; + margin-bottom: 0.5em; + font-weight: 600; +} +.release-notes-prose h2 { + font-size: 1.1em; + border-bottom: 1px solid oklch(from var(--color-base-content) l c h / 0.08); + padding-bottom: 0.3em; +} +.release-notes-prose p { + margin-top: 0.4em; + margin-bottom: 0.4em; +} +.release-notes-prose code { + background: oklch(from var(--color-base-content) l c h / 0.08); + padding: 0.15em 0.35em; + border-radius: 0.25em; + font-size: 0.875em; +} +.release-notes-prose a { + color: oklch(from var(--color-accent) l c h); + text-decoration: underline; + text-underline-offset: 2px; +} +.release-notes-prose a:hover { + opacity: 0.8; +} +.release-notes-prose blockquote { + border-left: 3px solid oklch(from var(--color-accent) l c h / 0.3); + padding-left: 1em; + margin: 0.5em 0; + color: oklch(from var(--color-base-content) l c h / 0.6); +} +.release-notes-prose pre { + background: oklch(from var(--color-base-content) l c h / 0.05); + padding: 0.75em 1em; + border-radius: 0.375em; + overflow-x: auto; + margin: 0.5em 0; +} +.release-notes-prose hr { + border-color: oklch(from var(--color-base-content) l c h / 0.1); + margin: 1em 0; +} diff --git a/frontend/src/lib/components/UpdateBanner.svelte b/frontend/src/lib/components/UpdateBanner.svelte new file mode 100644 index 0000000..18219ce --- /dev/null +++ b/frontend/src/lib/components/UpdateBanner.svelte @@ -0,0 +1,74 @@ + + +{#if showBanner} +
App version, updates, and release notes.
+Unable to load version information.
+ ++ Update available: {updateCheck.latest_version} +
++ {#if updateCheck.comparison_failed} + Simulated update - dev build can't compare versions. + {:else} + You're on {updateCheck.current_version}. A newer version is ready. + {/if} +
++ Released {formatDate(updateCheck.latest_release.published_at)} +
+ {/if} +Unable to load release history.
+ +No releases found.
+