mirror of
https://github.com/HabiRabbu/Musicseerr
synced 2026-04-21 13:37:27 +00:00
Version Info + Notifier (#51)
This commit is contained in:
parent
d24e26fb32
commit
1e7da40fef
27 changed files with 1085 additions and 11 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
29
backend/api/v1/routes/version.py
Normal file
29
backend/api/v1/routes/version.py
Normal 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()
|
||||
23
backend/api/v1/schemas/version.py
Normal file
23
backend/api/v1/schemas/version.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
2
backend/infrastructure/cache/cache_keys.py
vendored
2
backend/infrastructure/cache/cache_keys.py
vendored
|
|
@ -57,6 +57,8 @@ WIKIPEDIA_PREFIX = "wikipedia:extract:"
|
|||
|
||||
PREFERENCES_PREFIX = "preferences:"
|
||||
|
||||
GITHUB_RELEASES_PREFIX = "github:releases:"
|
||||
|
||||
AUDIODB_PREFIX = "audiodb_"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
83
backend/repositories/github_repository.py
Normal file
83
backend/repositories/github_repository.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
59
backend/services/version_service.py
Normal file
59
backend/services/version_service.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
74
frontend/src/lib/components/UpdateBanner.svelte
Normal file
74
frontend/src/lib/components/UpdateBanner.svelte
Normal 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>
|
||||
40
frontend/src/lib/components/VersionOverlays.svelte
Normal file
40
frontend/src/lib/components/VersionOverlays.svelte
Normal 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}
|
||||
/>
|
||||
167
frontend/src/lib/components/WhatsNewModal.svelte
Normal file
167
frontend/src/lib/components/WhatsNewModal.svelte
Normal 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>
|
||||
316
frontend/src/lib/components/settings/SettingsAbout.svelte
Normal file
316
frontend/src/lib/components/settings/SettingsAbout.svelte
Normal 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>
|
||||
|
|
@ -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') => {
|
||||
|
|
|
|||
53
frontend/src/lib/queries/VersionQuery.svelte.ts
Normal file
53
frontend/src/lib/queries/VersionQuery.svelte.ts
Normal 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
|
||||
}));
|
||||
6
frontend/src/lib/queries/VersionQueryKeyFactory.ts
Normal file
6
frontend/src/lib/queries/VersionQueryKeyFactory.ts
Normal 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
|
||||
};
|
||||
12
frontend/src/lib/stores/version.svelte.ts
Normal file
12
frontend/src/lib/stores/version.svelte.ts
Normal 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;
|
||||
}
|
||||
7
frontend/src/lib/utils/markdown.ts
Normal file
7
frontend/src/lib/utils/markdown.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue