Version Info + Notifier (#51)
This commit is contained in:
+2
-1
@@ -47,7 +47,8 @@ LABEL org.opencontainers.image.title="MusicSeerr" \
|
|||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PORT=8688 \
|
PORT=8688 \
|
||||||
COMMIT_TAG=${COMMIT_TAG}
|
COMMIT_TAG=${COMMIT_TAG} \
|
||||||
|
BUILD_DATE=${BUILD_DATE}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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_lastfm_repository,
|
||||||
get_playlist_repository,
|
get_playlist_repository,
|
||||||
get_request_history_store,
|
get_request_history_store,
|
||||||
|
get_github_repository,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .service_providers import ( # noqa: F401
|
from .service_providers import ( # noqa: F401
|
||||||
@@ -68,6 +69,7 @@ from .service_providers import ( # noqa: F401
|
|||||||
get_navidrome_playback_service,
|
get_navidrome_playback_service,
|
||||||
get_plex_library_service,
|
get_plex_library_service,
|
||||||
get_plex_playback_service,
|
get_plex_playback_service,
|
||||||
|
get_version_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .type_aliases import ( # noqa: F401
|
from .type_aliases import ( # noqa: F401
|
||||||
@@ -116,6 +118,8 @@ from .type_aliases import ( # noqa: F401
|
|||||||
PlexLibraryServiceDep,
|
PlexLibraryServiceDep,
|
||||||
PlexPlaybackServiceDep,
|
PlexPlaybackServiceDep,
|
||||||
CacheStatusServiceDep,
|
CacheStatusServiceDep,
|
||||||
|
GitHubRepositoryDep,
|
||||||
|
VersionServiceDep,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .cleanup import ( # noqa: F401
|
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_memory_cache_max_bytes=advanced.cover_memory_cache_max_size_mb * 1024 * 1024,
|
||||||
cover_non_monitored_ttl_seconds=advanced.cache_ttl_recently_viewed_bytes,
|
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_lastfm_repository,
|
||||||
get_playlist_repository,
|
get_playlist_repository,
|
||||||
get_request_history_store,
|
get_request_history_store,
|
||||||
|
get_github_repository,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -619,3 +620,11 @@ def get_plex_playback_service() -> "PlexPlaybackService":
|
|||||||
plex_repo = get_plex_repository()
|
plex_repo = get_plex_repository()
|
||||||
cache = get_cache()
|
cache = get_cache()
|
||||||
return PlexPlaybackService(plex_repo, 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.playlist_repository import PlaylistRepository
|
||||||
from repositories.navidrome_repository import NavidromeRepository
|
from repositories.navidrome_repository import NavidromeRepository
|
||||||
from repositories.plex_repository import PlexRepository
|
from repositories.plex_repository import PlexRepository
|
||||||
|
from repositories.github_repository import GitHubRepository
|
||||||
from services.preferences_service import PreferencesService
|
from services.preferences_service import PreferencesService
|
||||||
from services.search_service import SearchService
|
from services.search_service import SearchService
|
||||||
from services.search_enrichment_service import SearchEnrichmentService
|
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.lastfm_auth_service import LastFmAuthService
|
||||||
from services.scrobble_service import ScrobbleService
|
from services.scrobble_service import ScrobbleService
|
||||||
from services.cache_status_service import CacheStatusService
|
from services.cache_status_service import CacheStatusService
|
||||||
|
from services.version_service import VersionService
|
||||||
|
|
||||||
from .cache_providers import (
|
from .cache_providers import (
|
||||||
get_cache,
|
get_cache,
|
||||||
@@ -72,6 +74,7 @@ from .repo_providers import (
|
|||||||
get_request_history_store,
|
get_request_history_store,
|
||||||
get_navidrome_repository,
|
get_navidrome_repository,
|
||||||
get_plex_repository,
|
get_plex_repository,
|
||||||
|
get_github_repository,
|
||||||
)
|
)
|
||||||
from .service_providers import (
|
from .service_providers import (
|
||||||
get_search_service,
|
get_search_service,
|
||||||
@@ -101,6 +104,7 @@ from .service_providers import (
|
|||||||
get_navidrome_playback_service,
|
get_navidrome_playback_service,
|
||||||
get_plex_library_service,
|
get_plex_library_service,
|
||||||
get_plex_playback_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)]
|
PlexLibraryServiceDep = Annotated[PlexLibraryService, Depends(get_plex_library_service)]
|
||||||
PlexPlaybackServiceDep = Annotated[PlexPlaybackService, Depends(get_plex_playback_service)]
|
PlexPlaybackServiceDep = Annotated[PlexPlaybackService, Depends(get_plex_playback_service)]
|
||||||
CacheStatusServiceDep = Annotated[CacheStatusService, Depends(get_cache_status_service)]
|
CacheStatusServiceDep = Annotated[CacheStatusService, Depends(get_cache_status_service)]
|
||||||
|
GitHubRepositoryDep = Annotated[GitHubRepository, Depends(get_github_repository)]
|
||||||
|
VersionServiceDep = Annotated[VersionService, Depends(get_version_service)]
|
||||||
|
|||||||
+2
@@ -57,6 +57,8 @@ WIKIPEDIA_PREFIX = "wikipedia:extract:"
|
|||||||
|
|
||||||
PREFERENCES_PREFIX = "preferences:"
|
PREFERENCES_PREFIX = "preferences:"
|
||||||
|
|
||||||
|
GITHUB_RELEASES_PREFIX = "github:releases:"
|
||||||
|
|
||||||
AUDIODB_PREFIX = "audiodb_"
|
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 scrobble as scrobble_routes
|
||||||
from api.v1.routes import plex_library as plex_library_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 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')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
logger = logging.getLogger(__name__)
|
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(scrobble_routes.router)
|
||||||
v1_router.include_router(profile.router)
|
v1_router.include_router(profile.router)
|
||||||
v1_router.include_router(playlists.router)
|
v1_router.include_router(playlists.router)
|
||||||
|
v1_router.include_router(version_routes.router)
|
||||||
app.include_router(v1_router)
|
app.include_router(v1_router)
|
||||||
|
|
||||||
mount_frontend(app)
|
mount_frontend(app)
|
||||||
|
|||||||
@@ -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==2.12.0
|
||||||
pydantic-settings==2.3.0
|
pydantic-settings==2.3.0
|
||||||
uvicorn[standard]==0.37.0
|
uvicorn[standard]==0.37.0
|
||||||
|
packaging>=24.0
|
||||||
|
|||||||
@@ -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:
|
class TestSingletonRegistry:
|
||||||
def test_registry_has_expected_count(self):
|
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):
|
def test_all_entries_have_cache_clear(self):
|
||||||
for fn in _singleton_registry:
|
for fn in _singleton_registry:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||||
"@tailwindcss/cli": "^4.1.14",
|
"@tailwindcss/cli": "^4.1.14",
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@vitest/browser": "^3.2.4",
|
"@vitest/browser": "^3.2.4",
|
||||||
"daisyui": "^5.3.1",
|
"daisyui": "^5.3.1",
|
||||||
@@ -51,8 +52,10 @@
|
|||||||
"@tanstack/svelte-query": "^6.1.13",
|
"@tanstack/svelte-query": "^6.1.13",
|
||||||
"@tanstack/svelte-query-devtools": "^6.1.13",
|
"@tanstack/svelte-query-devtools": "^6.1.13",
|
||||||
"@tanstack/svelte-query-persist-client": "^6.1.13",
|
"@tanstack/svelte-query-persist-client": "^6.1.13",
|
||||||
|
"dompurify": "^3.4.0",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"lucide-svelte": "^0.575.0",
|
"lucide-svelte": "^0.575.0",
|
||||||
|
"marked": "^18.0.0",
|
||||||
"runed": "^0.37.1"
|
"runed": "^0.37.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+42
@@ -17,12 +17,18 @@ importers:
|
|||||||
'@tanstack/svelte-query-persist-client':
|
'@tanstack/svelte-query-persist-client':
|
||||||
specifier: ^6.1.13
|
specifier: ^6.1.13
|
||||||
version: 6.1.13(@tanstack/svelte-query@6.1.13(svelte@5.55.1))(svelte@5.55.1)
|
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:
|
idb-keyval:
|
||||||
specifier: ^6.2.2
|
specifier: ^6.2.2
|
||||||
version: 6.2.2
|
version: 6.2.2
|
||||||
lucide-svelte:
|
lucide-svelte:
|
||||||
specifier: ^0.575.0
|
specifier: ^0.575.0
|
||||||
version: 0.575.0(svelte@5.55.1)
|
version: 0.575.0(svelte@5.55.1)
|
||||||
|
marked:
|
||||||
|
specifier: ^18.0.0
|
||||||
|
version: 18.0.0
|
||||||
runed:
|
runed:
|
||||||
specifier: ^0.37.1
|
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)
|
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':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.1.14
|
specifier: ^4.1.14
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
|
'@tailwindcss/typography':
|
||||||
|
specifier: ^0.5.19
|
||||||
|
version: 0.5.19(tailwindcss@4.2.2)
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22
|
specifier: ^22
|
||||||
version: 22.19.17
|
version: 22.19.17
|
||||||
@@ -736,6 +745,11 @@ packages:
|
|||||||
'@tailwindcss/postcss@4.2.2':
|
'@tailwindcss/postcss@4.2.2':
|
||||||
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
|
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':
|
'@tanstack/query-core@5.96.2':
|
||||||
resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==}
|
resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==}
|
||||||
|
|
||||||
@@ -1044,6 +1058,9 @@ packages:
|
|||||||
dom-accessibility-api@0.5.16:
|
dom-accessibility-api@0.5.16:
|
||||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||||
|
|
||||||
|
dompurify@3.4.0:
|
||||||
|
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
|
||||||
|
|
||||||
enhanced-resolve@5.20.1:
|
enhanced-resolve@5.20.1:
|
||||||
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -1370,6 +1387,11 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
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:
|
minimatch@10.2.5:
|
||||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -1474,6 +1496,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.4.29
|
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:
|
postcss-selector-parser@7.1.1:
|
||||||
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -2233,6 +2259,11 @@ snapshots:
|
|||||||
postcss: 8.5.8
|
postcss: 8.5.8
|
||||||
tailwindcss: 4.2.2
|
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-core@5.96.2': {}
|
||||||
|
|
||||||
'@tanstack/query-devtools@5.96.2': {}
|
'@tanstack/query-devtools@5.96.2': {}
|
||||||
@@ -2556,6 +2587,10 @@ snapshots:
|
|||||||
|
|
||||||
dom-accessibility-api@0.5.16: {}
|
dom-accessibility-api@0.5.16: {}
|
||||||
|
|
||||||
|
dompurify@3.4.0:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
enhanced-resolve@5.20.1:
|
enhanced-resolve@5.20.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -2868,6 +2903,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
marked@18.0.0: {}
|
||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.5
|
brace-expansion: 5.0.5
|
||||||
@@ -2946,6 +2983,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.8
|
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:
|
postcss-selector-parser@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cssesc: 3.0.0
|
cssesc: 3.0.0
|
||||||
|
|||||||
+82
-3
@@ -1,4 +1,5 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
@plugin "daisyui" {
|
@plugin "daisyui" {
|
||||||
themes: dark --default;
|
themes: dark --default;
|
||||||
}
|
}
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
--animate-shimmer: shimmer 2s ease-in-out infinite;
|
--animate-shimmer: shimmer 2s ease-in-out infinite;
|
||||||
--animate-glow-pulse: glow-pulse 2.5s ease-in-out infinite;
|
--animate-glow-pulse: glow-pulse 2.5s ease-in-out infinite;
|
||||||
--animate-float: float 3s 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-note-float: note-float 6s ease-in-out infinite;
|
||||||
--animate-slide-in-left: slide-in-left 0.2s ease-out forwards;
|
--animate-slide-in-left: slide-in-left 0.2s ease-out forwards;
|
||||||
--animate-slide-in-right: slide-in-right 0.2s ease-out forwards;
|
--animate-slide-in-right: slide-in-right 0.2s ease-out forwards;
|
||||||
@@ -39,10 +40,10 @@
|
|||||||
@keyframes glow-pulse {
|
@keyframes glow-pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
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% {
|
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 {
|
@keyframes float {
|
||||||
@@ -347,6 +348,12 @@
|
|||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
.banner-enter,
|
||||||
|
.whats-new-modal .modal-box,
|
||||||
|
.animate-glow-pulse {
|
||||||
|
animation: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -416,3 +423,75 @@
|
|||||||
.now-playing-bars--sm span {
|
.now-playing-bars--sm span {
|
||||||
width: 2px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
@@ -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: {
|
charts: {
|
||||||
TIME_RANGE_OVERVIEW: 2 * 60 * 1000,
|
TIME_RANGE_OVERVIEW: 2 * 60 * 1000,
|
||||||
GENRE_DETAIL: 5 * 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;
|
} as const;
|
||||||
|
|
||||||
@@ -95,7 +100,8 @@ export const CACHE_TTL = {
|
|||||||
...CACHE_TTL_GROUPS.core,
|
...CACHE_TTL_GROUPS.core,
|
||||||
...CACHE_TTL_GROUPS.library,
|
...CACHE_TTL_GROUPS.library,
|
||||||
...CACHE_TTL_GROUPS.detail,
|
...CACHE_TTL_GROUPS.detail,
|
||||||
...CACHE_TTL_GROUPS.charts
|
...CACHE_TTL_GROUPS.charts,
|
||||||
|
...CACHE_TTL_GROUPS.version
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const API_SIZES = {
|
export const API_SIZES = {
|
||||||
@@ -432,6 +438,11 @@ export const API = {
|
|||||||
genreSongs: (genre: string, limit = 50, offset = 0) =>
|
genreSongs: (genre: string, limit = 50, offset = 0) =>
|
||||||
`/api/v1/plex/genres/songs?genre=${encodeURIComponent(genre)}&limit=${limit}&offset=${offset}`
|
`/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: {
|
local: {
|
||||||
albumMatch: (mbid: string) => `/api/v1/local/albums/match/${mbid}`,
|
albumMatch: (mbid: string) => `/api/v1/local/albums/match/${mbid}`,
|
||||||
albums: (limit = 50, offset = 0, sortBy = 'name', q?: string, sortOrder = 'asc') => {
|
albums: (limit = 50, offset = 0, sortBy = 'name', q?: string, sortOrder = 'asc') => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}));
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 PlexIcon from '$lib/components/PlexIcon.svelte';
|
||||||
import SidebarServiceHint from '$lib/components/SidebarServiceHint.svelte';
|
import SidebarServiceHint from '$lib/components/SidebarServiceHint.svelte';
|
||||||
import DegradedBanner from '$lib/components/DegradedBanner.svelte';
|
import DegradedBanner from '$lib/components/DegradedBanner.svelte';
|
||||||
|
import VersionOverlays from '$lib/components/VersionOverlays.svelte';
|
||||||
import SearchSuggestions from '$lib/components/SearchSuggestions.svelte';
|
import SearchSuggestions from '$lib/components/SearchSuggestions.svelte';
|
||||||
import type { SuggestResult } from '$lib/types';
|
import type { SuggestResult } from '$lib/types';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
@@ -52,7 +53,8 @@
|
|||||||
Info,
|
Info,
|
||||||
X,
|
X,
|
||||||
UserRound,
|
UserRound,
|
||||||
ListMusic
|
ListMusic,
|
||||||
|
ArrowUpCircle
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import QueryProvider from '$lib/queries/QueryProvider.svelte';
|
import QueryProvider from '$lib/queries/QueryProvider.svelte';
|
||||||
@@ -65,6 +67,7 @@
|
|||||||
let modalQuery = $state('');
|
let modalQuery = $state('');
|
||||||
let showNavigationProgress = $state(false);
|
let showNavigationProgress = $state(false);
|
||||||
let currentPath = $state('/');
|
let currentPath = $state('/');
|
||||||
|
let versionUpdateAvailable = $state(false);
|
||||||
|
|
||||||
const NAV_PROGRESS_DELAY_MS = 120;
|
const NAV_PROGRESS_DELAY_MS = 120;
|
||||||
const NAV_PROGRESS_MIN_VISIBLE_MS = 220;
|
const NAV_PROGRESS_MIN_VISIBLE_MS = 220;
|
||||||
@@ -250,6 +253,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<DegradedBanner />
|
<DegradedBanner />
|
||||||
|
<VersionOverlays bind:updateAvailable={versionUpdateAvailable} />
|
||||||
|
|
||||||
<div class="drawer drawer-open">
|
<div class="drawer drawer-open">
|
||||||
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
|
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
|
||||||
@@ -518,9 +522,23 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="w-full p-2 flex flex-col gap-1" class:pb-24={playerStore.isPlayerVisible}>
|
<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">
|
<div
|
||||||
<a href="/settings" class="btn btn-ghost btn-circle" aria-label="Settings">
|
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" />
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Open">
|
<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 SettingsScrobbling from '$lib/components/settings/SettingsScrobbling.svelte';
|
||||||
import SettingsMusicSource from '$lib/components/settings/SettingsMusicSource.svelte';
|
import SettingsMusicSource from '$lib/components/settings/SettingsMusicSource.svelte';
|
||||||
import SettingsAdvanced from '$lib/components/settings/SettingsAdvanced.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 {
|
import {
|
||||||
Settings2,
|
Settings2,
|
||||||
Music,
|
Music,
|
||||||
@@ -27,7 +29,9 @@
|
|||||||
Settings,
|
Settings,
|
||||||
Radio,
|
Radio,
|
||||||
Activity,
|
Activity,
|
||||||
BarChart3
|
BarChart3,
|
||||||
|
Info,
|
||||||
|
ArrowUpCircle
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
|
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
|
||||||
import NavidromeIcon from '$lib/components/NavidromeIcon.svelte';
|
import NavidromeIcon from '$lib/components/NavidromeIcon.svelte';
|
||||||
@@ -35,6 +39,9 @@
|
|||||||
|
|
||||||
const integration = fromStore(integrationStore);
|
const integration = fromStore(integrationStore);
|
||||||
|
|
||||||
|
const updateCheckQuery = getUpdateCheckQuery();
|
||||||
|
const updateAvailable = $derived(updateCheckQuery.data?.update_available ?? false);
|
||||||
|
|
||||||
const connectionMap: Record<
|
const connectionMap: Record<
|
||||||
string,
|
string,
|
||||||
| 'lastfm'
|
| 'lastfm'
|
||||||
@@ -77,7 +84,8 @@
|
|||||||
{ id: 'youtube', label: 'YouTube', group: 'Library & Sources', icon: Youtube },
|
{ id: 'youtube', label: 'YouTube', group: 'Library & Sources', icon: Youtube },
|
||||||
{ id: 'local-files', label: 'Local Files', group: 'Library & Sources', icon: Headphones },
|
{ id: 'local-files', label: 'Local Files', group: 'Library & Sources', icon: Headphones },
|
||||||
{ id: 'cache', label: 'Cache', group: 'System', icon: Database },
|
{ 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))];
|
const groups = [...new Set(tabs.map((t) => t.group))];
|
||||||
@@ -138,6 +146,14 @@
|
|||||||
<span class="sr-only">{connected ? 'Connected' : 'Not connected'}</span>
|
<span class="sr-only">{connected ? 'Connected' : 'Not connected'}</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -175,6 +191,8 @@
|
|||||||
<SettingsScrobbling />
|
<SettingsScrobbling />
|
||||||
{:else if activeTab === 'advanced'}
|
{:else if activeTab === 'advanced'}
|
||||||
<SettingsAdvanced />
|
<SettingsAdvanced />
|
||||||
|
{:else if activeTab === 'about'}
|
||||||
|
<SettingsAbout />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user