Version Info + Notifier (#51)

This commit is contained in:
Harvey 2026-04-16 00:51:24 +01:00 committed by GitHub
parent d24e26fb32
commit 1e7da40fef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1085 additions and 11 deletions

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)]

View file

@ -57,6 +57,8 @@ WIKIPEDIA_PREFIX = "wikipedia:extract:"
PREFERENCES_PREFIX = "preferences:"
GITHUB_RELEASES_PREFIX = "github:releases:"
AUDIODB_PREFIX = "audiodb_"

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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"
}
}

View file

@ -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

View file

@ -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;
}

View file

@ -0,0 +1,74 @@
<script lang="ts">
import { serviceStatusStore } from '$lib/stores/serviceStatus';
import { fromStore } from 'svelte/store';
import { PersistedState } from 'runed';
import { ArrowUpCircle, X } from 'lucide-svelte';
interface Props {
updateAvailable: boolean;
latestVersion: string | null;
}
let { updateAvailable, latestVersion }: Props = $props();
const status = fromStore(serviceStatusStore);
const dismissedVersion = new PersistedState<string | null>(
'musicseerr_update_banner_dismissed',
null
);
const degradedSources = $derived(Object.keys(status.current));
const hasDegradation = $derived(degradedSources.length > 0);
const showBanner = $derived(
updateAvailable && latestVersion !== null && dismissedVersion.current !== latestVersion
);
function dismiss() {
dismissedVersion.current = latestVersion;
}
</script>
{#if showBanner}
<div
class="fixed top-0 left-0 right-0 z-[114] flex items-center justify-center pointer-events-none"
class:mt-12={hasDegradation}
>
<div
class="alert alert-info shadow-lg mx-auto mt-2 max-w-xl pointer-events-auto text-sm gap-2 py-2 banner-enter"
role="status"
>
<ArrowUpCircle class="h-4 w-4 shrink-0" />
<span>
A new version of MusicSeerr is available
{#if latestVersion}
<span class="font-semibold">({latestVersion})</span>
{/if}
</span>
<a href="/settings?tab=about" class="btn btn-accent btn-xs btn-outline">Details</a>
<button
class="btn btn-ghost btn-sm btn-circle"
onclick={dismiss}
aria-label="Dismiss update notification"
>
<X class="h-3 w-3" />
</button>
</div>
</div>
{/if}
<style>
@keyframes slide-down {
0% {
opacity: 0;
transform: translateY(-100%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.banner-enter {
animation: slide-down 0.3s ease-out forwards;
}
</style>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import {
getUpdateCheckQuery,
getVersionQuery,
getReleaseHistoryQuery
} from '$lib/queries/VersionQuery.svelte';
import UpdateBanner from '$lib/components/UpdateBanner.svelte';
import WhatsNewModal from '$lib/components/WhatsNewModal.svelte';
let { updateAvailable = $bindable(false) }: { updateAvailable: boolean } = $props();
const updateCheckQuery = getUpdateCheckQuery();
const versionQuery = getVersionQuery();
const releaseHistoryQuery = getReleaseHistoryQuery();
const currentVersion = $derived(versionQuery.data?.version ?? null);
const buildDate = $derived(versionQuery.data?.build_date ?? null);
const isDev = $derived(currentVersion === 'dev' || currentVersion === 'hosting-local');
const currentRelease = $derived(
releaseHistoryQuery.data?.find((r) => r.tag_name === currentVersion) ??
(isDev ? releaseHistoryQuery.data?.[0] : null) ??
null
);
$effect(() => {
updateAvailable = updateCheckQuery.data?.update_available ?? false;
});
</script>
<UpdateBanner
updateAvailable={updateCheckQuery.data?.update_available ?? false}
latestVersion={updateCheckQuery.data?.latest_version ?? null}
/>
<WhatsNewModal
{currentVersion}
{buildDate}
releaseTag={currentRelease?.tag_name ?? null}
releaseBody={currentRelease?.body ?? null}
releaseName={currentRelease?.name ?? null}
/>

View file

@ -0,0 +1,167 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { isWhatsNewDismissed, dismissWhatsNew } from '$lib/stores/version.svelte';
import { renderMarkdown } from '$lib/utils/markdown';
import { X, Sparkles, ExternalLink } from 'lucide-svelte';
interface Props {
currentVersion: string | null;
buildDate: string | null;
releaseTag: string | null;
releaseBody: string | null;
releaseName: string | null;
}
let { currentVersion, buildDate, releaseTag, releaseBody, releaseName }: Props = $props();
let dialogEl: HTMLDialogElement | undefined = $state();
let renderedBody = $state('');
const isDev = $derived(currentVersion === 'dev' || currentVersion === 'hosting-local');
// In dev: key dismissal to build_date so modal shows once per rebuild, not every refresh
// In prod: key to release tag so modal shows once per new version
const dismissKey = $derived(isDev ? (buildDate ?? 'dev') : (releaseTag ?? currentVersion));
const shouldShow = $derived(
currentVersion !== null &&
dismissKey !== null &&
releaseBody !== null &&
releaseBody.trim().length > 0 &&
!isWhatsNewDismissed(dismissKey)
);
$effect(() => {
if (releaseBody && releaseBody.trim()) {
renderMarkdown(releaseBody)
.then((html) => {
renderedBody = html;
})
.catch(() => {
renderedBody = '';
});
}
});
$effect(() => {
if (shouldShow && dialogEl && !dialogEl.open) {
dialogEl.showModal();
}
});
function handleDismiss() {
if (dismissKey) {
dismissWhatsNew(dismissKey);
}
dialogEl?.close();
}
function onDialogClose() {
if (dismissKey) {
dismissWhatsNew(dismissKey);
}
}
function handleViewChangelog() {
handleDismiss();
goto('/settings?tab=about');
}
</script>
<dialog
bind:this={dialogEl}
class="modal whats-new-modal"
onclose={onDialogClose}
aria-labelledby="whats-new-title"
>
<div
class="modal-box whats-new-box max-w-2xl animate-fade-in-up border border-accent/10 p-5 sm:p-8"
>
<button
class="whats-new-close absolute right-2 top-2 flex h-11 w-11 items-center justify-center rounded-lg text-base-content/60 transition-all duration-200 hover:bg-base-content/8 hover:text-base-content/80"
onclick={handleDismiss}
aria-label="Close"
>
<X class="h-3.5 w-3.5" />
</button>
<div class="mb-6 flex items-center gap-4">
<div
class="whats-new-icon-wrap relative flex h-11 w-11 items-center justify-center rounded-xl bg-accent/10 animate-glow-pulse"
>
<Sparkles class="text-accent h-5 w-5" />
</div>
<div>
<h3 id="whats-new-title" class="text-xl font-bold tracking-tight">What's New</h3>
{#if currentVersion}
<p class="text-primary/70 mt-0.5 text-xs font-medium tracking-wide uppercase">
{currentVersion}
</p>
{/if}
</div>
</div>
<div class="divider my-0 opacity-10"></div>
{#if releaseName}
<p class="text-base-content mt-4 mb-4 text-sm font-semibold border-l-2 border-accent/50 pl-3">
{releaseName}
</p>
{/if}
{#if renderedBody}
<div
class="whats-new-content release-notes-prose prose prose-sm max-h-[55vh] max-w-none text-base-content/75 overflow-y-auto rounded-lg border border-base-content/5 bg-base-100/50 p-4 {releaseName
? ''
: 'mt-4'}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via DOMPurify -->
{@html renderedBody}
</div>
{:else}
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-md text-accent/60"></span>
</div>
{/if}
<div class="modal-action mt-6 gap-3">
<button
class="btn btn-ghost btn-sm text-base-content/60 hover:text-base-content/80 gap-1.5"
onclick={handleViewChangelog}
>
View full changelog
<ExternalLink class="h-3 w-3" />
</button>
<button class="btn btn-accent btn-sm px-6 shadow-sm shadow-accent/15" onclick={handleDismiss}
>Got it</button
>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<style>
.whats-new-box {
background:
radial-gradient(
ellipse at top left,
oklch(from var(--color-accent) l c h / 0.04),
transparent 50%
),
radial-gradient(
ellipse at bottom right,
oklch(from var(--color-primary) l c h / 0.03),
transparent 50%
),
var(--color-base-200);
box-shadow:
0 0 0 1px oklch(from var(--color-accent) l c h / 0.06),
0 24px 80px -12px rgba(0, 0, 0, 0.6);
}
.whats-new-content {
scrollbar-width: thin;
scrollbar-color: oklch(from var(--color-accent) l c h / 0.15) transparent;
}
</style>

View file

@ -0,0 +1,316 @@
<script lang="ts">
import {
getVersionQuery,
getUpdateCheckQuery,
getReleaseHistoryQuery
} from '$lib/queries/VersionQuery.svelte';
import { renderMarkdown } from '$lib/utils/markdown';
import {
ExternalLink,
RefreshCw,
Info,
Tag,
Calendar,
ArrowUpCircle,
Github
} from 'lucide-svelte';
const versionQuery = getVersionQuery();
const updateCheckQuery = getUpdateCheckQuery();
const releaseHistoryQuery = getReleaseHistoryQuery();
const version = $derived(versionQuery.data);
const updateCheck = $derived(updateCheckQuery.data);
const releases = $derived(releaseHistoryQuery.data);
const isCheckingUpdate = $derived(updateCheckQuery.isFetching);
let latestReleaseHtml = $state('');
let releaseHtmlMap = $state<Record<string, string>>({});
$effect(() => {
const body = updateCheck?.latest_release?.body;
if (body) {
renderMarkdown(body)
.then((html) => {
latestReleaseHtml = html;
})
.catch(() => {
latestReleaseHtml = '';
});
} else {
latestReleaseHtml = '';
}
});
$effect(() => {
if (!releases) return;
const currentMap = releaseHtmlMap;
for (const release of releases) {
if (release.tag_name in currentMap) continue;
const tag = release.tag_name;
renderMarkdown(release.body ?? '')
.then((html) => {
releaseHtmlMap = { ...releaseHtmlMap, [tag]: html };
})
.catch(() => {});
}
});
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
</script>
<div class="space-y-6 stagger-fade-in">
<div>
<h2 class="text-2xl font-bold">About</h2>
<p class="text-base-content/60 mt-1">App version, updates, and release notes.</p>
</div>
{#if versionQuery.isLoading}
<div class="flex justify-center items-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if versionQuery.isError}
<div class="card bg-base-200 shadow-md border border-base-content/5">
<div class="card-body items-center text-center">
<Info class="w-10 h-10 text-base-content/50 mb-2" />
<p class="text-base-content/70">Unable to load version information.</p>
<button class="btn btn-primary btn-sm mt-3" onclick={() => versionQuery.refetch()}>
Try Again
</button>
</div>
</div>
{:else if version}
<div class="card bg-base-200 shadow-md border border-primary/10 relative overflow-hidden">
<div
class="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5 pointer-events-none"
></div>
<div class="card-body relative">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center gap-4">
<div class="bg-primary/10 p-3 rounded-xl">
<Info class="w-7 h-7 text-primary" />
</div>
<div>
<h3 class="text-xl font-bold">MusicSeerr</h3>
<div class="flex items-center gap-2 mt-1">
<span class="badge badge-accent font-mono">
{version.version}
</span>
{#if version.build_date}
<span class="flex items-center gap-1 text-xs text-base-content/60">
<Calendar class="w-3 h-3" />
Built {formatDate(version.build_date)}
</span>
{/if}
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
class="btn btn-primary btn-sm"
onclick={() => updateCheckQuery.refetch()}
disabled={isCheckingUpdate}
>
{#if isCheckingUpdate}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<RefreshCw class="w-4 h-4" />
{/if}
Check for Updates
</button>
<a
href="https://github.com/HabiRabbu/Musicseerr"
target="_blank"
rel="noopener noreferrer"
class="btn btn-ghost btn-sm"
>
<Github class="w-4 h-4" />
View on GitHub
<ExternalLink class="w-3 h-3" />
</a>
</div>
</div>
{#if updateCheck}
{#if updateCheck.update_available && updateCheck.latest_version}
<div class="alert alert-info alert-soft mt-4">
<ArrowUpCircle class="w-5 h-5 shrink-0" />
<div>
<p class="font-semibold">
Update available: <span class="text-accent">{updateCheck.latest_version}</span>
</p>
<p class="text-sm opacity-80">
{#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}
</p>
</div>
{#if updateCheck.latest_release}
<a
href={updateCheck.latest_release.html_url}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-ghost ml-auto"
>
View Release
<ExternalLink class="w-3 h-3" />
</a>
{/if}
</div>
{:else if updateCheck.comparison_failed}
<div class="alert alert-warning alert-soft mt-4">
<Info class="w-5 h-5 shrink-0" />
<span>Couldn't compare versions - unrecognized format.</span>
</div>
{:else}
<div class="alert alert-success alert-soft mt-4">
<ArrowUpCircle class="w-5 h-5 shrink-0" />
<span>You're on the latest version.</span>
</div>
{/if}
{:else if updateCheckQuery.isError}
<div class="alert alert-warning alert-soft mt-4">
<Info class="w-5 h-5 shrink-0" />
<span>Couldn't check for updates.</span>
</div>
{/if}
</div>
</div>
{/if}
{#if updateCheck?.update_available && updateCheck.latest_release}
<div class="card bg-base-200 shadow-md border border-base-content/5">
<div class="card-body">
<div class="flex items-center gap-3 mb-4">
<div class="bg-accent/10 p-2 rounded-lg">
<Tag class="w-5 h-5 text-accent" />
</div>
<div>
<h3 class="font-semibold text-lg">
What's New in <span class="text-accent">{updateCheck.latest_version}</span>
</h3>
{#if updateCheck.latest_release.published_at}
<p class="text-xs text-base-content/60">
Released {formatDate(updateCheck.latest_release.published_at)}
</p>
{/if}
</div>
</div>
{#if latestReleaseHtml}
<div class="release-notes-prose prose prose-sm max-w-none text-base-content/80">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via DOMPurify -->
{@html latestReleaseHtml}
</div>
{:else}
<div class="flex justify-center py-4">
<span class="loading loading-spinner loading-sm text-base-content/40"></span>
</div>
{/if}
</div>
</div>
{/if}
<div>
<div class="flex items-center gap-3 mb-4">
<h3 class="text-lg font-semibold">Release History</h3>
</div>
{#if releaseHistoryQuery.isLoading}
<div class="space-y-3">
{#each Array(3) as _, i (i)}
<div class="card bg-base-200 shadow-md border border-base-content/5 skeleton-shimmer">
<div class="card-body py-4">
<div class="flex items-center gap-3">
<div class="h-5 w-16 bg-base-content/10 rounded"></div>
<div class="h-4 w-40 bg-base-content/10 rounded"></div>
<div class="ml-auto h-3 w-24 bg-base-content/10 rounded"></div>
</div>
</div>
</div>
{/each}
</div>
{:else if releaseHistoryQuery.isError}
<div class="card bg-base-200 shadow-md border border-base-content/5">
<div class="card-body items-center text-center">
<Info class="w-8 h-8 text-base-content/50 mb-2" />
<p class="text-base-content/70">Unable to load release history.</p>
<button class="btn btn-primary btn-sm mt-3" onclick={() => releaseHistoryQuery.refetch()}>
Try Again
</button>
</div>
</div>
{:else if releases && releases.length > 0}
<div class="space-y-3 border-l-2 border-accent/20 pl-6">
{#each releases as release (release.tag_name)}
<div
class="collapse collapse-arrow bg-base-200 rounded-box shadow-md border border-base-content/5 hover:shadow-lg hover:border-accent/10 transition-all duration-200 relative"
>
<div
class="absolute -left-[1.75rem] top-5 w-2.5 h-2.5 rounded-full bg-accent/40 ring-2 ring-base-100"
></div>
<input
type="checkbox"
aria-label="Toggle {release.name ?? release.tag_name} release notes"
/>
<div class="collapse-title">
<div class="flex items-center gap-3 flex-wrap">
<span class="badge badge-accent badge-sm font-mono">{release.tag_name}</span>
{#if release.prerelease}
<span class="badge badge-ghost badge-sm">pre-release</span>
{/if}
<span class="text-sm font-medium">{release.name ?? release.tag_name}</span>
{#if release.published_at}
<span class="ml-auto text-xs text-base-content/60 flex items-center gap-1">
<Calendar class="w-3 h-3" />
{formatDate(release.published_at)}
</span>
{/if}
</div>
</div>
<div class="collapse-content">
{#if releaseHtmlMap[release.tag_name]}
<div
class="release-notes-prose prose prose-sm max-w-none text-base-content/80 pt-2"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via DOMPurify -->
{@html releaseHtmlMap[release.tag_name]}
</div>
{:else}
<div class="flex justify-center py-4">
<span class="loading loading-spinner loading-sm text-base-content/40"></span>
</div>
{/if}
<div class="mt-4 pt-3 border-t border-base-content/10">
<a
href={release.html_url}
target="_blank"
rel="noopener noreferrer"
class="link link-primary text-sm inline-flex items-center gap-1"
>
View on GitHub
<ExternalLink class="w-3 h-3" />
</a>
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="card bg-base-200 shadow-md border border-base-content/5">
<div class="card-body items-center text-center py-8">
<Tag class="w-8 h-8 text-base-content/50 mb-2" />
<p class="text-base-content/70">No releases found.</p>
</div>
</div>
{/if}
</div>
</div>

View file

@ -88,6 +88,11 @@ export const CACHE_TTL_GROUPS = {
charts: {
TIME_RANGE_OVERVIEW: 2 * 60 * 1000,
GENRE_DETAIL: 5 * 60 * 1000
},
version: {
VERSION_INFO: 60 * 60 * 1000,
UPDATE_CHECK: 30 * 60 * 1000,
RELEASE_HISTORY: 60 * 60 * 1000
}
} as const;
@ -95,7 +100,8 @@ export const CACHE_TTL = {
...CACHE_TTL_GROUPS.core,
...CACHE_TTL_GROUPS.library,
...CACHE_TTL_GROUPS.detail,
...CACHE_TTL_GROUPS.charts
...CACHE_TTL_GROUPS.charts,
...CACHE_TTL_GROUPS.version
} as const;
export const API_SIZES = {
@ -432,6 +438,11 @@ export const API = {
genreSongs: (genre: string, limit = 50, offset = 0) =>
`/api/v1/plex/genres/songs?genre=${encodeURIComponent(genre)}&limit=${limit}&offset=${offset}`
},
version: {
info: () => '/api/v1/version',
checkUpdate: () => '/api/v1/version/check-update',
releases: () => '/api/v1/version/releases'
},
local: {
albumMatch: (mbid: string) => `/api/v1/local/albums/match/${mbid}`,
albums: (limit = 50, offset = 0, sortBy = 'name', q?: string, sortOrder = 'asc') => {

View file

@ -0,0 +1,53 @@
import { api } from '$lib/api/client';
import { API, CACHE_TTL } from '$lib/constants';
import { createQuery } from '@tanstack/svelte-query';
import { VersionQueryKeyFactory } from './VersionQueryKeyFactory';
export interface VersionInfo {
version: string;
build_date: string | null;
}
export interface GitHubRelease {
tag_name: string;
name: string | null;
body: string | null;
published_at: string;
html_url: string;
prerelease: boolean;
}
export interface UpdateCheckResponse {
current_version: string;
latest_version: string | null;
update_available: boolean;
comparison_failed: boolean;
latest_release: GitHubRelease | null;
}
export const getVersionQuery = () =>
createQuery(() => ({
staleTime: CACHE_TTL.VERSION_INFO,
queryKey: VersionQueryKeyFactory.info(),
queryFn: ({ signal }) => api.global.get<VersionInfo>(API.version.info(), { signal }),
refetchOnWindowFocus: false
}));
export const getUpdateCheckQuery = () =>
createQuery(() => ({
staleTime: CACHE_TTL.UPDATE_CHECK,
queryKey: VersionQueryKeyFactory.updateCheck(),
queryFn: ({ signal }) =>
api.global.get<UpdateCheckResponse>(API.version.checkUpdate(), { signal }),
refetchOnWindowFocus: false,
refetchOnReconnect: false
}));
export const getReleaseHistoryQuery = () =>
createQuery(() => ({
staleTime: CACHE_TTL.RELEASE_HISTORY,
queryKey: VersionQueryKeyFactory.releases(),
queryFn: ({ signal }) => api.global.get<GitHubRelease[]>(API.version.releases(), { signal }),
refetchOnWindowFocus: false,
refetchOnReconnect: false
}));

View file

@ -0,0 +1,6 @@
export const VersionQueryKeyFactory = {
prefix: ['version'] as const,
info: () => [...VersionQueryKeyFactory.prefix, 'info'] as const,
updateCheck: () => [...VersionQueryKeyFactory.prefix, 'update-check'] as const,
releases: () => [...VersionQueryKeyFactory.prefix, 'releases'] as const
};

View file

@ -0,0 +1,12 @@
import { PersistedState } from 'runed';
// svelte-ignore state_referenced_locally
const dismissedVersion = new PersistedState<string | null>('musicseerr_whats_new_dismissed', null);
export function isWhatsNewDismissed(currentVersion: string): boolean {
return dismissedVersion.current === currentVersion;
}
export function dismissWhatsNew(version: string): void {
dismissedVersion.current = version;
}

View file

@ -0,0 +1,7 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
export async function renderMarkdown(md: string): Promise<string> {
const raw = await marked(md);
return DOMPurify.sanitize(raw);
}

View file

@ -28,6 +28,7 @@
import PlexIcon from '$lib/components/PlexIcon.svelte';
import SidebarServiceHint from '$lib/components/SidebarServiceHint.svelte';
import DegradedBanner from '$lib/components/DegradedBanner.svelte';
import VersionOverlays from '$lib/components/VersionOverlays.svelte';
import SearchSuggestions from '$lib/components/SearchSuggestions.svelte';
import type { SuggestResult } from '$lib/types';
import { onMount, onDestroy } from 'svelte';
@ -52,7 +53,8 @@
Info,
X,
UserRound,
ListMusic
ListMusic,
ArrowUpCircle
} from 'lucide-svelte';
import type { Snippet } from 'svelte';
import QueryProvider from '$lib/queries/QueryProvider.svelte';
@ -65,6 +67,7 @@
let modalQuery = $state('');
let showNavigationProgress = $state(false);
let currentPath = $state('/');
let versionUpdateAvailable = $state(false);
const NAV_PROGRESS_DELAY_MS = 120;
const NAV_PROGRESS_MIN_VISIBLE_MS = 220;
@ -250,6 +253,7 @@
{/if}
<DegradedBanner />
<VersionOverlays bind:updateAvailable={versionUpdateAvailable} />
<div class="drawer drawer-open">
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
@ -518,9 +522,23 @@
{/if}
</ul>
<div class="w-full p-2 flex flex-col gap-1" class:pb-24={playerStore.isPlayerVisible}>
<div class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<a href="/settings" class="btn btn-ghost btn-circle" aria-label="Settings">
<div
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
data-tip={versionUpdateAvailable ? 'Settings - update available' : 'Settings'}
>
<a
href={versionUpdateAvailable ? '/settings?tab=about' : '/settings'}
class="btn btn-ghost btn-circle relative"
aria-label={versionUpdateAvailable ? 'Settings - update available' : 'Settings'}
>
<Settings class="h-6 w-6" />
{#if versionUpdateAvailable}
<span
class="absolute -top-0.5 -right-0.5 flex h-4.5 w-4.5 items-center justify-center rounded-full bg-accent text-accent-content shadow-sm shadow-accent/30"
>
<ArrowUpCircle class="h-3 w-3" />
</span>
{/if}
</a>
</div>
<div class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Open">

View file

@ -17,6 +17,8 @@
import SettingsScrobbling from '$lib/components/settings/SettingsScrobbling.svelte';
import SettingsMusicSource from '$lib/components/settings/SettingsMusicSource.svelte';
import SettingsAdvanced from '$lib/components/settings/SettingsAdvanced.svelte';
import SettingsAbout from '$lib/components/settings/SettingsAbout.svelte';
import { getUpdateCheckQuery } from '$lib/queries/VersionQuery.svelte';
import {
Settings2,
Music,
@ -27,7 +29,9 @@
Settings,
Radio,
Activity,
BarChart3
BarChart3,
Info,
ArrowUpCircle
} from 'lucide-svelte';
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
import NavidromeIcon from '$lib/components/NavidromeIcon.svelte';
@ -35,6 +39,9 @@
const integration = fromStore(integrationStore);
const updateCheckQuery = getUpdateCheckQuery();
const updateAvailable = $derived(updateCheckQuery.data?.update_available ?? false);
const connectionMap: Record<
string,
| 'lastfm'
@ -77,7 +84,8 @@
{ id: 'youtube', label: 'YouTube', group: 'Library & Sources', icon: Youtube },
{ id: 'local-files', label: 'Local Files', group: 'Library & Sources', icon: Headphones },
{ id: 'cache', label: 'Cache', group: 'System', icon: Database },
{ id: 'advanced', label: 'Advanced', group: 'System', icon: Settings }
{ id: 'advanced', label: 'Advanced', group: 'System', icon: Settings },
{ id: 'about', label: 'About', group: 'System', icon: Info }
];
const groups = [...new Set(tabs.map((t) => t.group))];
@ -138,6 +146,14 @@
<span class="sr-only">{connected ? 'Connected' : 'Not connected'}</span>
</span>
{/if}
{#if tab.id === 'about' && updateAvailable}
<span
class="ml-auto flex items-center gap-1 rounded-full bg-accent/15 px-2 py-0.5 text-xs font-semibold text-accent"
>
<ArrowUpCircle class="h-3 w-3" />
Update
</span>
{/if}
</button>
</li>
{/each}
@ -175,6 +191,8 @@
<SettingsScrobbling />
{:else if activeTab === 'advanced'}
<SettingsAdvanced />
{:else if activeTab === 'about'}
<SettingsAbout />
{/if}
</main>
</div>