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} +
+ +
+{/if} + + diff --git a/frontend/src/lib/components/VersionOverlays.svelte b/frontend/src/lib/components/VersionOverlays.svelte new file mode 100644 index 0000000..6e73078 --- /dev/null +++ b/frontend/src/lib/components/VersionOverlays.svelte @@ -0,0 +1,40 @@ + + + + diff --git a/frontend/src/lib/components/WhatsNewModal.svelte b/frontend/src/lib/components/WhatsNewModal.svelte new file mode 100644 index 0000000..f7fda5a --- /dev/null +++ b/frontend/src/lib/components/WhatsNewModal.svelte @@ -0,0 +1,167 @@ + + + + + + + + diff --git a/frontend/src/lib/components/settings/SettingsAbout.svelte b/frontend/src/lib/components/settings/SettingsAbout.svelte new file mode 100644 index 0000000..37d609c --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsAbout.svelte @@ -0,0 +1,316 @@ + + +
+
+

About

+

App version, updates, and release notes.

+
+ + {#if versionQuery.isLoading} +
+ +
+ {:else if versionQuery.isError} +
+
+ +

Unable to load version information.

+ +
+
+ {:else if version} +
+
+
+
+
+
+ +
+
+

MusicSeerr

+
+ + {version.version} + + {#if version.build_date} + + + Built {formatDate(version.build_date)} + + {/if} +
+
+
+ +
+ + + + View on GitHub + + +
+
+ + {#if updateCheck} + {#if updateCheck.update_available && updateCheck.latest_version} +
+ +
+

+ 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} +

+
+ {#if updateCheck.latest_release} + + View Release + + + {/if} +
+ {:else if updateCheck.comparison_failed} +
+ + Couldn't compare versions - unrecognized format. +
+ {:else} +
+ + You're on the latest version. +
+ {/if} + {:else if updateCheckQuery.isError} +
+ + Couldn't check for updates. +
+ {/if} +
+
+ {/if} + + {#if updateCheck?.update_available && updateCheck.latest_release} +
+
+
+
+ +
+
+

+ What's New in {updateCheck.latest_version} +

+ {#if updateCheck.latest_release.published_at} +

+ Released {formatDate(updateCheck.latest_release.published_at)} +

+ {/if} +
+
+ {#if latestReleaseHtml} +
+ + {@html latestReleaseHtml} +
+ {:else} +
+ +
+ {/if} +
+
+ {/if} + +
+
+

Release History

+
+ + {#if releaseHistoryQuery.isLoading} +
+ {#each Array(3) as _, i (i)} +
+
+
+
+
+
+
+
+
+ {/each} +
+ {:else if releaseHistoryQuery.isError} +
+
+ +

Unable to load release history.

+ +
+
+ {:else if releases && releases.length > 0} +
+ {#each releases as release (release.tag_name)} +
+
+ +
+
+ {release.tag_name} + {#if release.prerelease} + pre-release + {/if} + {release.name ?? release.tag_name} + {#if release.published_at} + + + {formatDate(release.published_at)} + + {/if} +
+
+
+ {#if releaseHtmlMap[release.tag_name]} +
+ + {@html releaseHtmlMap[release.tag_name]} +
+ {:else} +
+ +
+ {/if} + +
+
+ {/each} +
+ {:else} +
+
+ +

No releases found.

+
+
+ {/if} +
+
diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index 6ae38a8..8ba30a8 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -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') => { diff --git a/frontend/src/lib/queries/VersionQuery.svelte.ts b/frontend/src/lib/queries/VersionQuery.svelte.ts new file mode 100644 index 0000000..032c4fb --- /dev/null +++ b/frontend/src/lib/queries/VersionQuery.svelte.ts @@ -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(API.version.info(), { signal }), + refetchOnWindowFocus: false + })); + +export const getUpdateCheckQuery = () => + createQuery(() => ({ + staleTime: CACHE_TTL.UPDATE_CHECK, + queryKey: VersionQueryKeyFactory.updateCheck(), + queryFn: ({ signal }) => + api.global.get(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(API.version.releases(), { signal }), + refetchOnWindowFocus: false, + refetchOnReconnect: false + })); diff --git a/frontend/src/lib/queries/VersionQueryKeyFactory.ts b/frontend/src/lib/queries/VersionQueryKeyFactory.ts new file mode 100644 index 0000000..e1f3853 --- /dev/null +++ b/frontend/src/lib/queries/VersionQueryKeyFactory.ts @@ -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 +}; diff --git a/frontend/src/lib/stores/version.svelte.ts b/frontend/src/lib/stores/version.svelte.ts new file mode 100644 index 0000000..ba0144f --- /dev/null +++ b/frontend/src/lib/stores/version.svelte.ts @@ -0,0 +1,12 @@ +import { PersistedState } from 'runed'; + +// svelte-ignore state_referenced_locally +const dismissedVersion = new PersistedState('musicseerr_whats_new_dismissed', null); + +export function isWhatsNewDismissed(currentVersion: string): boolean { + return dismissedVersion.current === currentVersion; +} + +export function dismissWhatsNew(version: string): void { + dismissedVersion.current = version; +} diff --git a/frontend/src/lib/utils/markdown.ts b/frontend/src/lib/utils/markdown.ts new file mode 100644 index 0000000..89249c0 --- /dev/null +++ b/frontend/src/lib/utils/markdown.ts @@ -0,0 +1,7 @@ +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +export async function renderMarkdown(md: string): Promise { + const raw = await marked(md); + return DOMPurify.sanitize(raw); +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index f4d30d7..30647ff 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -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} +
@@ -518,9 +522,23 @@ {/if}
-
- +
diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 2cc3e73..83d890d 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -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 @@ {connected ? 'Connected' : 'Not connected'} {/if} + {#if tab.id === 'about' && updateAvailable} + + + Update + + {/if} {/each} @@ -175,6 +191,8 @@ {:else if activeTab === 'advanced'} + {:else if activeTab === 'about'} + {/if}