From 48cae853752b1da5f0f0956d4bd52eb849075971 Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Fri, 1 May 2026 17:59:26 -0500 Subject: [PATCH 1/6] feat: add version bump script and update related changelog and resources - Introduced `scripts/bump_version.sh` to manage version propagation across projects. - Updated `.csproj` files to reference the script. - Enhanced onboarding UI with inline configuration editing. - Added `Save` and `Enter Value` translations to `Resources/SharedResources.resx`. - Added operation timing in `StatisticsService` for enhanced logging. --- design/docs/VERSION_GUIDE.md | 249 ++++++++ docs/_data/toc.yml | 3 + docs/pages/changelog.md | 90 +++ scripts/bump_version.sh | 192 ++++++ .../Components/Components/ThemeSelector.razor | 4 +- .../Onboarding/OnboardingVerify.razor | 75 ++- .../Components/Pages/Dashboard.razor | 560 +++++++++++++----- .../Pages/Onboarding/Blocking.razor | 79 ++- .../Resources/SharedResources.resx | 6 + src/Melodee.Blazor/Services/DoctorService.cs | 45 +- src/Melodee.Blazor/wwwroot/app.css | 37 ++ .../Services/Setup/SetupCheckService.cs | 9 +- .../Services/StatisticsService.cs | 97 +-- 13 files changed, 1213 insertions(+), 233 deletions(-) create mode 100644 design/docs/VERSION_GUIDE.md create mode 100644 docs/pages/changelog.md create mode 100755 scripts/bump_version.sh diff --git a/design/docs/VERSION_GUIDE.md b/design/docs/VERSION_GUIDE.md new file mode 100644 index 000000000..648af6301 --- /dev/null +++ b/design/docs/VERSION_GUIDE.md @@ -0,0 +1,249 @@ +# Melodee Version Guide + +This document outlines how application versioning works in Melodee and the steps required to bump versions following [Semantic Versioning](https://semver.org/) (SemVer). + +## Semantic Versioning Overview + +Melodee uses the `MAJOR.MINOR.PATCH` format: + +| Segment | When to Increment | Example | +|---------|-------------------|---------| +| **MAJOR** | Incompatible API changes, breaking UI/behavior changes, or major architectural shifts | `1.x.x` → `2.0.0` | +| **MINOR** | New backward-compatible features, new endpoints, new UI pages | `1.2.x` → `1.3.0` | +| **PATCH** | Bug fixes, security patches, performance improvements, documentation | `1.2.3` → `1.2.4` | + +### Decision Guide + +**Increment MAJOR when:** +- Removing or changing existing API endpoints in a breaking way +- Changing database schemas in a non-migratable way +- Removing or renaming configuration settings without backward compatibility +- Dropping support for existing client integrations (OpenSubsonic, Jellyfin-compatible API) +- Major UI overhaul that changes established user workflows + +**Increment MINOR when:** +- Adding new API endpoints (non-breaking) +- Adding new UI features or pages (e.g., Party Mode, Jukebox, Podcasts) +- Adding new configuration settings with sensible defaults +- Adding new plugin types or extensibility points +- Adding new localization support + +**Increment PATCH when:** +- Fixing bugs in existing functionality +- Updating dependencies for security patches +- Performance optimizations that don't change behavior +- UI/UX polish (styling, accessibility improvements) +- Documentation updates +- CI/CD pipeline fixes + +## Current Versioning Architecture + +Melodee has **two independent version tracks** that must be kept in sync during releases: + +### Track 1: Assembly Version (.csproj files) + +Used at runtime, displayed in the About page, and embedded in compiled binaries. + +**Files (all 4 must be updated together):** + +| File | Property | Current Value | +|------|----------|---------------| +| `src/Melodee.Blazor/Melodee.Blazor.csproj` | `VersionPrefix` | `2.0.0` | +| `src/Melodee.Common/Melodee.Common.csproj` | `VersionPrefix` | `2.0.0` | +| `src/Melodee.Cli/Melodee.Cli.csproj` | `VersionPrefix` | `2.0.0` | +| `src/Melodee.Mql/Melodee.Mql.csproj` | `VersionPrefix` | `2.0.0` | + +Each .csproj also defines: +- `VersionSuffix` — auto-generated build timestamp (e.g., `build20260501165851`) +- `AssemblyVersion` — `$(VersionPrefix).0` (e.g., `2.0.0.0`) +- `FileVersion` — `$(VersionPrefix).0` (e.g., `2.0.0.0`) +- `InformationalVersion` — `$(VersionPrefix)+$(VersionSuffix)` (e.g., `2.0.0+build20260501165851`) + +The `AppVersionProvider` service strips the suffix and displays only the `VersionPrefix` (e.g., `2.0.0`) in the UI. + +### Track 2: Docker Image Tags (GitHub Releases) + +Docker images are published to `ghcr.io` and tagged based on **GitHub release tags**. + +**Workflow:** `.github/workflows/docker-publish.yml` + +**Trigger:** GitHub release published (or manual `workflow_dispatch`) + +**Tag patterns:** +- `{{version}}` — full SemVer (e.g., `2.0.0`) +- `{{major}}.{{minor}}` — minor track (e.g., `2.0`) +- `{{major}}` — major track (e.g., `2`) +- `latest` — applied to every release + +### Track 3: docs/VERSION (Orphaned) + +The file `docs/VERSION` currently contains `0.0.31` and is **not referenced by any code, build, or CI pipeline**. It appears to be a legacy artifact. Consider removing it or integrating it into the release process. + +## Step-by-Step Version Bump Procedure + +### Prerequisites + +- All changes for the release are merged to `main` +- CI pipeline (`.github/workflows/dotnet.yml`) passes on `main` +- You have write access to the repository + +### Step 1: Update the Changelog + +Edit `docs/pages/changelog.md` and: + +1. Replace the `[Unreleased]` section header with the new version and today's date: + ```markdown + ## [X.Y.Z] - YYYY-MM-DD + ``` + +2. Add a fresh `[Unreleased]` section at the top for future entries: + ```markdown + ## [Unreleased] + ``` + +3. Categorize all changes since the last release using [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) types: + + | Type | Use For | + |------|---------| + | **Added** | New features, endpoints, UI pages, configuration options | + | **Changed** | Modifications to existing functionality or behavior | + | **Deprecated** | Features that will be removed in a future release | + | **Removed** | Features removed in this release | + | **Fixed** | Bug fixes and error corrections | + | **Security** | Vulnerability patches and security hardening | + +4. Source changes from: + - Git commit history since the last tag (`git log vX.Y.Z..HEAD --oneline`) + - Merged PRs and their labels + - The GitHub release draft notes + +### Step 2: Update Assembly Versions + +Edit the `VersionPrefix` in all four .csproj files: + +``` +src/Melodee.Blazor/Melodee.Blazor.csproj +src/Melodee.Common/Melodee.Common.csproj +src/Melodee.Cli/Melodee.Cli.csproj +src/Melodee.Mql/Melodee.Mql.csproj +``` + +Change `X.Y.Z` to the new version. + +> **Future improvement:** Centralize this in `Directory.Build.props` so all projects inherit a single version definition: +> ```xml +> +> +> 2.1.0 +> $(MelodeeVersion) +> build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) +> $(VersionPrefix).0 +> $(VersionPrefix).0 +> $(VersionPrefix)+$(VersionSuffix) +> +> +> ``` + +### Step 3: Commit and Open a PR + +```bash +git add docs/pages/changelog.md src/*/ docs/VERSION 2>/dev/null || true +git commit -m "chore: release vX.Y.Z" +git push origin +``` + +Open a PR targeting `main`. The version bump is reviewed like any other change. + +### Step 4: Merge and Tag + +After the PR is approved and merged to `main`: + +```bash +git checkout main && git pull +git tag -a vX.Y.Z -m "Release vX.Y.Z" +git push origin vX.Y.Z +``` + +> The tag **must** be created on `main` after merge so the `docker-publish.yml` workflow picks it up. + +### Step 5: Create a GitHub Release + +1. Go to **GitHub → Releases → Draft a new release** +2. Select the tag `vX.Y.Z` +3. Title: `vX.Y.Z` +4. Description: Copy the changelog entries for this version from `docs/pages/changelog.md` (everything under the `## [X.Y.Z]` header down to the next `##` heading). Use the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) section headings: + + ```markdown + ### Added + - New feature description + + ### Changed + - Modified behavior description + + ### Fixed + - Bug fix description + + ### Security + - Security improvement description + ``` + +5. Click **Publish release** + +### Step 6: Verify Docker Image Publication + +After publishing the release, the `docker-publish.yml` workflow triggers automatically. Verify: + +1. Check the **Actions** tab for the `Docker Publish` workflow run +2. Confirm all platforms build successfully (`linux/amd64`, `linux/arm64`) +3. Verify the multi-platform manifest is created + +Pull and test the image: + +```bash +docker pull ghcr.io//melodee:X.Y.Z +``` + +### Step 7: Verify the About Page and Changelog + +After deploying: + +1. Navigate to the **About** page in the Melodee UI and confirm the displayed version matches the new `X.Y.Z`. +2. Navigate to the **Changelog** page on the docs site (`/changelog/`) and confirm the new version entry is visible. + +## Version Display Locations + +| Location | Source | Format | +|----------|--------|--------| +| About page | `IAppVersionProvider.GetSemVerForDisplay()` | `2.0.0` (prefix only) | +| Admin Dashboard → Server Stats | `Assembly.GetName().Version` | `2.0.0.0` | +| Admin Doctor → Server Info | `Assembly.GetName().Version` | `2.0.0.0` | +| Docker image tags | GitHub release tag | `2.0.0`, `2.0`, `2`, `latest` | +| Assembly metadata | `InformationalVersion` | `2.0.0+build20260501165851` | + +## API Versioning (Separate from App Version) + +Melodee uses `Asp.Versioning.Mvc` for REST API versioning, which is **independent** of the application SemVer. + +- Current API version: `v1` +- Defined in `Program.cs` via `AddApiVersioning()` +- Consumers specify version via URL segment (`/api/v1/...`) or `X-Api-Version` header +- API version bumps are independent of application version bumps + +## Automated Version Bumping (Future) + +Consider adopting one of these tools for automated version management: + +| Tool | Approach | Best For | +|------|----------|----------| +| **GitVersion** | Derives version from Git history/branches | Teams using GitFlow | +| **MinVer** | Uses Git tags as version source | Simple tag-based releases | +| **Nerdbank.GitVersioning** | `version.json` file + Git commits | Precise, deterministic builds | +| **Conventional Commits + release-please** | Auto-generates changelog + releases from commit messages | Automated CI/CD pipelines | + +Using **MinVer** as an example, you could replace all manual .csproj version properties with: + +```xml + +``` + +Then the version is derived entirely from Git tags (`v2.0.0` → assembly version `2.0.0`), eliminating the need to edit .csproj files during releases. diff --git a/docs/_data/toc.yml b/docs/_data/toc.yml index 25c72b6e3..76a3270f4 100644 --- a/docs/_data/toc.yml +++ b/docs/_data/toc.yml @@ -65,3 +65,6 @@ - title: About url: "about" + links: + - title: "Changelog" + url: "changelog" diff --git a/docs/pages/changelog.md b/docs/pages/changelog.md new file mode 100644 index 000000000..f11be2a5a --- /dev/null +++ b/docs/pages/changelog.md @@ -0,0 +1,90 @@ +## [Unreleased] + +--- +title: Changelog +permalink: /changelog/ +--- + +# Changelog + +All notable changes to Melodee will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Types of Changes + +- **Added** — New features, endpoints, UI pages, or configuration options. +- **Changed** — Modifications to existing functionality or behavior. +- **Deprecated** — Features that will be removed in a future release. +- **Removed** — Features that were removed in this release. +- **Fixed** — Bug fixes and error corrections. +- **Security** — Vulnerability patches and security hardening. + +--- + +## [2.0.1] - 2026-05-01 + +### Added + +- Dashboard now loads progressively with skeleton placeholders; each data section renders independently as its query completes instead of blocking the entire page. +- Inline setting editor on the Onboarding verification step — failed configuration checks (e.g., `system.baseUrl`) now show a text input and Save button so admins can fix settings without navigating away. +- `Disabled` parameter on the `ThemeSelector` component for use in read‑only profile forms. + +### Fixed + +- Dashboard `OnInitializedAsync` no longer runs twice during prerender/interactive transition, eliminating duplicate database queries on page load. +- Profile page crash caused by missing `Disabled` parameter on `ThemeSelector`. + +--- + +## [2.0.0] - 2026-05-01 + +> **v2.0.0** marks the current major release line of Melodee, built on .NET 10 with Blazor Server UI, OpenSubsonic-compatible API, and a native Melodee REST API. + +### Added + +- Blazor Server administrative UI with Radzen component library. +- OpenSubsonic-compatible API for third-party client support. +- Native Melodee REST API with versioned endpoints (`/api/v1/`). +- Party Mode for collaborative queue management. +- Jukebox playback mode for server-side audio playback. +- Podcast discovery, subscription, and playback. +- Event scripting engine for custom automation. +- MQL (Melodee Query Language) for advanced search and filtering. +- Scrobbling support (Last.fm and compatible services). +- User sharing and playlist management. +- Custom theming with Radzen theme support and custom CSS overrides. +- Multi-library support (Inbound, Staging, Storage). +- Background job scheduling with Quartz.NET. +- Doctor diagnostics for server health checks. +- Onboarding wizard for first-time setup. +- Request system for user-submitted metadata corrections. +- Radio station management. +- Chart generation and display. +- User device profiles. +- Multi-language localization (en-US, de-DE, es-ES, fr-FR, it-IT, ja-JP, pt-BR, ru-RU, zh-CN, ar-SA). +- Docker multi-arch images (linux/amd64, linux/arm64) via GitHub Container Registry. +- Scalar OpenAPI documentation UI. +- Rate limiting for API and authentication endpoints. +- JWT and cookie-based authentication. +- CORS policy configuration. +- ETag and response compression support. +- Custom block system for page customization via Markdown/HTML. + +### Changed + +- Migrated to .NET 10 runtime. +- Migrated from SQLite to [DecentDB](https://github.com/sphildreth/decentdb) +- Centralized configuration via `IMelodeeConfigurationFactory` with environment variable overrides. + +### Fixed + +- Various stability and performance improvements across scan pipeline and API endpoints. + +### Security + +- SSRF validation for podcast and external URL fetching. +- Secret redaction in configuration exports and logs. +- CSRF protection via antiforgery tokens. +- HSTS and security headers middleware. diff --git a/scripts/bump_version.sh b/scripts/bump_version.sh new file mode 100755 index 000000000..482a71425 --- /dev/null +++ b/scripts/bump_version.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# +# bump_version.sh — propagate a new version across all Melodee projects. +# +# Usage: +# ./scripts/bump_version.sh # interactive prompt +# ./scripts/bump_version.sh 2.1.0 # bump to 2.1.0 +# ./scripts/bump_version.sh --dry-run 2.1.0 # preview changes only +# +# This script updates: +# - All 4 .csproj VersionPrefix values +# - docs/pages/changelog.md (promotes [Unreleased] to the new version) +# - docs/VERSION (if it exists) +# +# After merging the PR, create the git tag and GitHub Release from the tag. +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +DRY_RUN=false +VERSION="" + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + -h|--help) + echo "Usage: $0 [--dry-run] [VERSION]" + echo "" + echo "Options:" + echo " --dry-run Show what would change without modifying files" + echo " VERSION Semantic version (e.g., 2.1.0). Prompts if omitted." + exit 0 + ;; + *) + if [[ -z "$VERSION" ]]; then + VERSION="$arg" + else + echo "Error: unexpected argument '$arg'" >&2 + exit 1 + fi + ;; + esac +done + +# Prompt for version if not provided +if [[ -z "$VERSION" ]]; then + # Get current version from first .csproj + CURRENT_VERSION="$(grep -oP '(?<=)[^<]+' src/Melodee.Blazor/Melodee.Blazor.csproj | head -1)" + echo "Current version: $CURRENT_VERSION" + read -rp "Enter new version (SemVer): " VERSION +fi + +# Trim whitespace +VERSION="$(echo "$VERSION" | tr -d '[:space:]')" + +# Validate semver +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "Error: '$VERSION' is not a valid semver string" >&2 + exit 1 +fi + +# Determine current version +CURRENT_VERSION="$(grep -oP '(?<=)[^<]+' src/Melodee.Blazor/Melodee.Blazor.csproj | head -1)" + +if [[ "$VERSION" == "$CURRENT_VERSION" ]]; then + echo "Version is already $VERSION. No changes needed." + exit 0 +fi + +echo "" +echo "Bumping version: $CURRENT_VERSION → $VERSION" +echo "" + +# Track updated files +updated=0 +skipped=0 + +log_update() { + local file="$1" + local desc="$2" + if [[ "$DRY_RUN" == true ]]; then + echo " [DRY] $file — $desc" + else + echo " OK $file — $desc" + fi + updated=$((updated + 1)) +} + +log_skip() { + local file="$1" + local reason="$2" + echo " SKIP $file — $reason" + skipped=$((skipped + 1)) +} + +# --- Update .csproj files --- +CSPROJ_FILES=( + "src/Melodee.Blazor/Melodee.Blazor.csproj" + "src/Melodee.Common/Melodee.Common.csproj" + "src/Melodee.Cli/Melodee.Cli.csproj" + "src/Melodee.Mql/Melodee.Mql.csproj" +) + +for csproj in "${CSPROJ_FILES[@]}"; do + if [[ ! -f "$csproj" ]]; then + log_skip "$csproj" "file not found" + continue + fi + + old_ver="$(grep -oP '(?<=)[^<]+' "$csproj" | head -1)" + if [[ "$old_ver" == "$VERSION" ]]; then + log_skip "$csproj" "already at $VERSION" + continue + fi + + if [[ "$DRY_RUN" == true ]]; then + log_update "$csproj" "VersionPrefix: $old_ver → $VERSION" + else + sed -i "s|${old_ver}|${VERSION}|" "$csproj" + log_update "$csproj" "VersionPrefix: $old_ver → $VERSION" + fi +done + +# --- Update docs/VERSION (if exists) --- +VERSION_FILE="docs/VERSION" +if [[ -f "$VERSION_FILE" ]]; then + old_ver="$(cat "$VERSION_FILE" | tr -d '[:space:]')" + if [[ "$old_ver" == "$VERSION" ]]; then + log_skip "$VERSION_FILE" "already at $VERSION" + elif [[ "$DRY_RUN" == true ]]; then + log_update "$VERSION_FILE" "$old_ver → $VERSION" + else + echo "$VERSION" > "$VERSION_FILE" + log_update "$VERSION_FILE" "$old_ver → $VERSION" + fi +else + log_skip "$VERSION_FILE" "file does not exist (legacy/orphaned)" +fi + +# --- Update changelog --- +CHANGELOG="docs/pages/changelog.md" +if [[ -f "$CHANGELOG" ]]; then + if [[ "$DRY_RUN" == true ]]; then + log_update "$CHANGELOG" "promote [Unreleased] → [$VERSION]" + else + today="$(date +%Y-%m-%d)" + + # Check if [Unreleased] section exists + if grep -q "^## \[Unreleased\]" "$CHANGELOG"; then + # Replace [Unreleased] header with versioned header + date + sed -i "s|^## \[Unreleased\]|## [${VERSION}] - ${today}|" "$CHANGELOG" + + # Insert a fresh [Unreleased] section before the new version header + sed -i "1i\\ +## [Unreleased]\\ +" "$CHANGELOG" + + log_update "$CHANGELOG" "promoted [Unreleased] → [$VERSION] - $today" + else + log_skip "$CHANGELOG" "no [Unreleased] section found" + fi + fi +else + log_skip "$CHANGELOG" "file not found" +fi + +echo "" + +if [[ "$DRY_RUN" == true ]]; then + echo "Dry run complete. $updated files would be updated." + echo "" + echo "Run without --dry-run to apply changes." + exit 0 +fi + +echo "Updated $updated file(s) to version $VERSION" +echo "" + +# --- Git status --- +echo "Changed files:" +git diff --name-only +echo "" + +echo "Next steps:" +echo " 1. Commit and push: git commit -am \"chore: bump version to v${VERSION}\" && git push" +echo " 2. Open a PR and get it approved/merged" +echo " 3. After merge, create the tag: git tag -a v${VERSION} -m \"Release v${VERSION}\" && git push origin v${VERSION}" +echo " 4. Create GitHub Release from tag v${VERSION} (triggers Docker publish)" +echo " 5. Verify About page shows v${VERSION} in the UI" +echo " 6. Verify /changelog/ docs page shows the new entry" diff --git a/src/Melodee.Blazor/Components/Components/ThemeSelector.razor b/src/Melodee.Blazor/Components/Components/ThemeSelector.razor index bd87acae8..a32cfff25 100644 --- a/src/Melodee.Blazor/Components/Components/ThemeSelector.razor +++ b/src/Melodee.Blazor/Components/Components/ThemeSelector.razor @@ -14,11 +14,13 @@ ValueProperty="Value" Change="@OnThemeChangedAsync" Style="width: 100%" - Loading="@_loading"/> + Loading="@_loading" + Disabled="@Disabled"/> @code { [Parameter] public string? Value { get; set; } [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public bool Disabled { get; set; } private string _selectedTheme = "melodee-dark"; private bool _loading = true; diff --git a/src/Melodee.Blazor/Components/Onboarding/OnboardingVerify.razor b/src/Melodee.Blazor/Components/Onboarding/OnboardingVerify.razor index 62baa8e4b..2c8249986 100644 --- a/src/Melodee.Blazor/Components/Onboarding/OnboardingVerify.razor +++ b/src/Melodee.Blazor/Components/Onboarding/OnboardingVerify.razor @@ -1,4 +1,5 @@ @using Melodee.Blazor.Components.Pages +@using Melodee.Common.Services @using Melodee.Common.Services.Setup @using Melodee.Blazor.Services @inherits MelodeeComponentBase @@ -20,11 +21,32 @@ @foreach (var item in _items) { - - - - + + + + + + + @if (!item.Success && !string.IsNullOrEmpty(item.SettingKey)) + { + + + + @if (_saveResults.TryGetValue(item.SettingKey, out var saveResult)) + { + + } + + } } @@ -71,10 +93,14 @@ private bool _isReady; private bool _isCompleting; private bool _isRefreshing; + private HashSet _savingKeys = new(); + private Dictionary _settingValues = new(); + private Dictionary _saveResults = new(); [Inject] private OnboardingStateService OnboardingStateService { get; set; } = null!; [Inject] private IJSRuntime JSRuntime { get; set; } = null!; [Inject] private ChecklistService ChecklistService { get; set; } = null!; + [Inject] private SettingService SettingService { get; set; } = null!; [Parameter] public EventCallback OnBack { get; set; } [Parameter] public EventCallback OnComplete { get; set; } @@ -92,6 +118,7 @@ private async Task RefreshStatusAsync() { _isRefreshing = true; + _saveResults.Clear(); try { await OnboardingStateService.RefreshSetupStatusAsync(); @@ -101,6 +128,14 @@ _totalCount = _items.Count; _blockingCount = status.BlockingItems.Count; _isReady = status.IsReady; + + foreach (var item in _items.Where(i => !string.IsNullOrEmpty(i.SettingKey) && !i.Success)) + { + if (!_settingValues.ContainsKey(item.SettingKey!)) + { + _settingValues[item.SettingKey!] = string.Empty; + } + } } finally { @@ -108,6 +143,36 @@ } } + private async Task SaveSettingAsync(string key) + { + if (!_settingValues.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) + { + _saveResults[key] = false; + await InvokeAsync(StateHasChanged); + return; + } + + _savingKeys.Add(key); + try + { + var result = await SettingService.SetAsync(key, value.Trim()); + _saveResults[key] = result.IsSuccess; + + if (result.IsSuccess) + { + await RefreshStatusAsync(); + } + } + catch + { + _saveResults[key] = false; + } + finally + { + _savingKeys.Remove(key); + } + } + private async Task Back() => await OnBack.InvokeAsync(); private async Task Complete() diff --git a/src/Melodee.Blazor/Components/Pages/Dashboard.razor b/src/Melodee.Blazor/Components/Pages/Dashboard.razor index 38b6f1b35..40136d196 100644 --- a/src/Melodee.Blazor/Components/Pages/Dashboard.razor +++ b/src/Melodee.Blazor/Components/Pages/Dashboard.razor @@ -8,6 +8,7 @@ @using Melodee.Common.Models @using Melodee.Common.Models.Collection @using Melodee.Common.Models.Collection.Extensions +@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components.Authorization @using NodaTime @using TextStyle = Radzen.Blazor.TextStyle @@ -23,6 +24,7 @@ @inject NavigationManager NavigationManager @inject IDoctorService DoctorService @inject AuthenticationStateProvider DashboardAuthenticationStateProvider +@inject MainLayoutProxyService MainLayoutProxyService @L("Navigation.Dashboard") @@ -31,7 +33,13 @@ @* Admin Health Warning Banner *@ - @if (_showHealthWarning && _isAdmin) + @if (_healthLoading) + { + + + + } + else if (_showHealthWarning && _isAdmin) { @@ -46,7 +54,13 @@ } - @if (_userPins.Length > 0) + @if (_pinsLoading) + { + + + + } + else if (_userPins.Length > 0) { @@ -73,43 +87,70 @@ } - - @foreach (var kpi in _userKpis) - { - - - - - - @LocalizeStatisticTitle(kpi.Title) + @if (_kpisLoading) + { + + @for (int i = 0; i < 6; i++) + { + + + + + + } + + } + else + { + + @foreach (var kpi in _userKpis) + { + + + + + + @LocalizeStatisticTitle(kpi.Title) + + @kpi.Data - @kpi.Data - - - - } - + + + } + + } @L("Dashboard.YourPlaysLast30Days") - - - - - - - - - - + @if (_playsLoading) + { + + } + else + { + + + + + + + + + + + } @L("Dashboard.TopPlayedSongsLast30Days") - @if (_topSongs.Length > 0) + @if (_topSongsLoading) + { + + } + else if (_topSongs.Length > 0) { - @if (_recentlyPlayed.Length > 0) + @if (_recentlyPlayedLoading) + { + + + + } + else if (_recentlyPlayed.Length > 0) { @@ -162,7 +209,13 @@ } - @if (_unreadRequests.Length > 0) + @if (_requestsLoading) + { + + + + } + else if (_unreadRequests.Length > 0) { @@ -208,65 +261,85 @@ } - - - - - @L("Dashboard.RecentlyAddedArtists") - - - - @if (_latestArtists.Length > 0) - { - - @foreach (var artist in _latestArtists) - { - - } - - } - else - { - @L("Dashboard.NoArtistsFound") - } - - - - - - - @L("Dashboard.RecentlyAddedAlbums") - - - - @if (_latestAlbums.Length > 0) - { - - @foreach (var album in _latestAlbums) - { - - } - - } - else - { - @L("Dashboard.NoAlbumsFound") - } - - + @if (_artistsLoading) + { + + + + } + else + { + + + + + @L("Dashboard.RecentlyAddedArtists") + + + + @if (_latestArtists.Length > 0) + { + + @foreach (var artist in _latestArtists) + { + + } + + } + else + { + @L("Dashboard.NoArtistsFound") + } + + + } + + @if (_albumsLoading) + { + + + + } + else + { + + + + + @L("Dashboard.RecentlyAddedAlbums") + + + + @if (_latestAlbums.Length > 0) + { + + @foreach (var album in _latestAlbums) + { + + } + + } + else + { + @L("Dashboard.NoAlbumsFound") + } + + + } @code { + [CascadingParameter] private HttpContext? HttpContext { get; set; } private AlbumDataInfo[] _latestAlbums = []; private ArtistDataInfo[] _latestArtists = []; @@ -280,6 +353,17 @@ private bool _showHealthWarning; private bool _isAdmin; + private bool _healthLoading = true; + private bool _pinsLoading = true; + private bool _kpisLoading = true; + private bool _playsLoading = true; + private bool _topSongsLoading = true; + private bool _recentlyPlayedLoading = true; + private bool _requestsLoading = true; + private bool _artistsLoading = true; + private bool _albumsLoading = true; + private bool _isLoading => _healthLoading || _pinsLoading || _kpisLoading || _playsLoading || _topSongsLoading || _recentlyPlayedLoading || _requestsLoading || _artistsLoading || _albumsLoading; + private short _latestPageSize; private LocalDate _rangeStart; private LocalDate _rangeEnd; @@ -311,6 +395,12 @@ protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); + + if (HttpContext != null) + { + return; + } + DashboardAuthenticationStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged; _isAdmin = CurrentUser?.IsAdmin() == true; @@ -324,96 +414,248 @@ _rangeStart = today.PlusDays(-29); var userApiKey = CurrentUser?.ToApiGuid() ?? Guid.Empty; + var userId = CurrentUser?.UserId() ?? 0; + + MainLayoutProxyService.ShowSpinner = true; + + _ = LoadHealthAsync(); + _ = LoadPinsAsync(userId); + _ = LoadKpisAsync(userApiKey); + _ = LoadPlaysAsync(userApiKey); + _ = LoadTopSongsAsync(userApiKey); + _ = LoadRecentlyPlayedAsync(userApiKey); + _ = LoadRequestsAsync(userId); + _ = LoadArtistsAsync(); + _ = LoadAlbumsAsync(); + } + + private void UpdateSpinner() + { + var anyLoading = _healthLoading || _pinsLoading || _kpisLoading || _playsLoading || _topSongsLoading || _recentlyPlayedLoading || _requestsLoading || _artistsLoading || _albumsLoading; + MainLayoutProxyService.ShowSpinner = anyLoading; + } - var albumLatestTask = AlbumService.ListAsync(new PagedRequest + private async Task LoadHealthAsync() + { + try { - PageSize = _latestPageSize, - OrderBy = new Dictionary - { - { nameof(AlbumDataInfo.CreatedAt), PagedRequest.OrderDescDirection } - } - }); + _showHealthWarning = await DashboardHealthWarningEvaluator.ShouldShowAsync(CurrentUser, DoctorService); + } + catch + { + _showHealthWarning = false; + } + finally + { + _healthLoading = false; + UpdateSpinner(); + await InvokeAsync(StateHasChanged); + } + } - var artistLatestTask = ArtistService.ListAsync(new PagedRequest + private async Task LoadPinsAsync(int userId) + { + try { - PageSize = _latestPageSize, - OrderBy = new Dictionary + var userTask = await UserProfileService.GetAsync(userId); + if (userTask is { IsSuccess: true, Data: not null }) { - { nameof(ArtistDataInfo.CreatedAt), PagedRequest.OrderDescDirection } + _userPins = userTask.Data.Pins.ToArray(); } - }); + } + catch + { + _userPins = []; + } + finally + { + _pinsLoading = false; + UpdateSpinner(); + await InvokeAsync(StateHasChanged); + } + } - var userTask = UserProfileService.GetAsync(CurrentUser?.UserId() ?? 0); - var kpiTask = StatisticsService.GetUserKpisAsync(userApiKey, _rangeStart, _rangeEnd, _timeZoneId); - var playsTask = StatisticsService.GetUserSongPlaysPerDayAsync(userApiKey, _rangeStart, _rangeEnd, _timeZoneId); - var topSongsTask = StatisticsService.GetUserTopPlayedSongsAsync(userApiKey, _rangeStart, _rangeEnd, _timeZoneId, 10); - var recentlyPlayedTask = StatisticsService.GetUserRecentlyPlayedSongsAsync(userApiKey, 10); - var unreadRequestsTask = RequestActivityService.GetUnreadRequestsAsync(CurrentUser?.UserId() ?? 0, new PagedRequest { PageSize = 10 }); + private async Task LoadKpisAsync(Guid userApiKey) + { + try + { + var result = await StatisticsService.GetUserKpisAsync(userApiKey, _rangeStart, _rangeEnd, _timeZoneId); + _userKpis = result.Data ?? []; + } + catch + { + _userKpis = []; + } + finally + { + _kpisLoading = false; + UpdateSpinner(); + await InvokeAsync(StateHasChanged); + } + } - var healthCheckTask = DashboardHealthWarningEvaluator.ShouldShowAsync(CurrentUser, DoctorService); + private async Task LoadPlaysAsync(Guid userApiKey) + { + try + { + var result = await StatisticsService.GetUserSongPlaysPerDayAsync(userApiKey, _rangeStart, _rangeEnd, _timeZoneId); + _playsPerDay = result.Data ?? []; + } + catch + { + _playsPerDay = []; + } + finally + { + _playsLoading = false; + UpdateSpinner(); + await InvokeAsync(StateHasChanged); + } + } - var tasksToAwait = new List + private async Task LoadTopSongsAsync(Guid userApiKey) + { + try + { + var result = await StatisticsService.GetUserTopPlayedSongsAsync(userApiKey, _rangeStart, _rangeEnd, _timeZoneId, 10); + _topSongs = result.Data ?? []; + } + catch + { + _topSongs = []; + } + finally { - albumLatestTask, - artistLatestTask, - userTask, - kpiTask, - playsTask, - topSongsTask, - recentlyPlayedTask, - unreadRequestsTask, - healthCheckTask - }; - await Task.WhenAll(tasksToAwait); + _topSongsLoading = false; + UpdateSpinner(); + await InvokeAsync(StateHasChanged); + } + } - _latestAlbums = albumLatestTask.Result.Data.ToArray(); - _latestArtists = artistLatestTask.Result.Data.ToArray(); - _userKpis = kpiTask.Result.Data ?? []; - _playsPerDay = playsTask.Result.Data ?? []; - _topSongs = topSongsTask.Result.Data ?? []; - _recentlyPlayed = recentlyPlayedTask.Result.Data ?? []; - _unreadRequests = unreadRequestsTask.Result.Data.ToArray(); - _showHealthWarning = healthCheckTask.Result; + private async Task LoadRecentlyPlayedAsync(Guid userApiKey) + { + try + { + var result = await StatisticsService.GetUserRecentlyPlayedSongsAsync(userApiKey, 10); + _recentlyPlayed = result.Data ?? []; - if (userTask.Result is { IsSuccess: true, Data: not null }) + if (_recentlyPlayed.Length > 0) + { + _recentlyPlayedSongs = _recentlyPlayed + .Where(x => x.ApiKey.HasValue) + .Select(x => + { + var parts = x.Extra?.Split('|') ?? []; + var albumApiKey = parts.Length > 0 && Guid.TryParse(parts[0], out var albumGuid) ? albumGuid : Guid.Empty; + var artistApiKey = parts.Length > 1 && Guid.TryParse(parts[1], out var artistGuid) ? artistGuid : Guid.Empty; + var albumName = parts.Length > 2 ? parts[2] : string.Empty; + var artistName = parts.Length > 3 ? parts[3] : string.Empty; + var songNumber = parts.Length > 4 && int.TryParse(parts[4], out var num) ? num : 0; + var duration = parts.Length > 5 && double.TryParse(parts[5], out var dur) ? dur : 0; + + return new SongDataInfo( + x.Id ?? 0, + x.ApiKey!.Value, + false, + x.Label, + x.Label.ToNormalizedString() ?? string.Empty, + songNumber, + LocalDate.MinIsoValue, + albumName, + albumApiKey, + artistName, + artistApiKey, + 0, + duration, + Instant.MinValue, + string.Empty, + false, + 0); + }) + .ToArray(); + } + } + catch + { + _recentlyPlayed = []; + _recentlyPlayedSongs = []; + } + finally { - _userPins = userTask.Result.Data.Pins.ToArray(); + _recentlyPlayedLoading = false; + UpdateSpinner(); + await InvokeAsync(StateHasChanged); } + } - if (_recentlyPlayed.Length > 0) + private async Task LoadRequestsAsync(int userId) + { + try { - _recentlyPlayedSongs = _recentlyPlayed - .Where(x => x.ApiKey.HasValue) - .Select(x => + var result = await RequestActivityService.GetUnreadRequestsAsync(userId, new PagedRequest { PageSize = 10 }); + _unreadRequests = result.Data.ToArray(); + } + catch + { + _unreadRequests = []; + } + finally + { + _requestsLoading = false; + UpdateSpinner(); + await InvokeAsync(StateHasChanged); + } + } + + private async Task LoadArtistsAsync() + { + try + { + var result = await ArtistService.ListAsync(new PagedRequest + { + PageSize = _latestPageSize, + OrderBy = new Dictionary + { + { nameof(ArtistDataInfo.CreatedAt), PagedRequest.OrderDescDirection } + } + }); + _latestArtists = result.Data.ToArray(); + } + catch + { + _latestArtists = []; + } + finally + { + _artistsLoading = false; + UpdateSpinner(); + await InvokeAsync(StateHasChanged); + } + } + + private async Task LoadAlbumsAsync() + { + try + { + var result = await AlbumService.ListAsync(new PagedRequest + { + PageSize = _latestPageSize, + OrderBy = new Dictionary { - var parts = x.Extra?.Split('|') ?? []; - var albumApiKey = parts.Length > 0 && Guid.TryParse(parts[0], out var albumGuid) ? albumGuid : Guid.Empty; - var artistApiKey = parts.Length > 1 && Guid.TryParse(parts[1], out var artistGuid) ? artistGuid : Guid.Empty; - var albumName = parts.Length > 2 ? parts[2] : string.Empty; - var artistName = parts.Length > 3 ? parts[3] : string.Empty; - var songNumber = parts.Length > 4 && int.TryParse(parts[4], out var num) ? num : 0; - var duration = parts.Length > 5 && double.TryParse(parts[5], out var dur) ? dur : 0; - - return new SongDataInfo( - x.Id ?? 0, - x.ApiKey!.Value, - false, - x.Label, - x.Label.ToNormalizedString() ?? string.Empty, - songNumber, - LocalDate.MinIsoValue, - albumName, - albumApiKey, - artistName, - artistApiKey, - 0, - duration, - Instant.MinValue, - string.Empty, - false, - 0); - }) - .ToArray(); + { nameof(AlbumDataInfo.CreatedAt), PagedRequest.OrderDescDirection } + } + }); + _latestAlbums = result.Data.ToArray(); + } + catch + { + _latestAlbums = []; + } + finally + { + _albumsLoading = false; + UpdateSpinner(); + await InvokeAsync(StateHasChanged); } } diff --git a/src/Melodee.Blazor/Components/Pages/Onboarding/Blocking.razor b/src/Melodee.Blazor/Components/Pages/Onboarding/Blocking.razor index cb931b1c6..aab83dc1b 100644 --- a/src/Melodee.Blazor/Components/Pages/Onboarding/Blocking.razor +++ b/src/Melodee.Blazor/Components/Pages/Onboarding/Blocking.razor @@ -1,4 +1,5 @@ @page "/onboarding/blocking" +@using Melodee.Common.Services @using Melodee.Common.Services.Setup @using Melodee.Blazor.Services @inherits MelodeeComponentBase @@ -25,12 +26,35 @@ @foreach (var item in _blockingItems) { - - - @if (!string.IsNullOrEmpty(item.Remediation)) - { - - } + + + + + + @if (!item.Success && !string.IsNullOrEmpty(item.SettingKey)) + { + + + + @if (_saveResults.TryGetValue(item.SettingKey, out var saveResult)) + { + + } + + } + else if (!string.IsNullOrEmpty(item.Remediation)) + { + + } + } @@ -54,9 +78,13 @@ private IReadOnlyList _blockingItems = Array.Empty(); private bool _isRetrying; private string? _blockingErrorMessage; + private HashSet _savingKeys = new(); + private Dictionary _settingValues = new(); + private Dictionary _saveResults = new(); [Inject] private OnboardingStateService OnboardingStateService { get; set; } = null!; [Inject] private NavigationManager NavigationManager { get; set; } = null!; + [Inject] private SettingService SettingService { get; set; } = null!; protected override async Task OnInitializedAsync() { @@ -69,6 +97,14 @@ { _blockingItems = await OnboardingStateService.GetBlockingItemsAsync(); _blockingErrorMessage = OnboardingStateService.LastSetupErrorMessage; + + foreach (var item in _blockingItems.Where(i => !string.IsNullOrEmpty(i.SettingKey))) + { + if (!_settingValues.ContainsKey(item.SettingKey!)) + { + _settingValues[item.SettingKey!] = string.Empty; + } + } } catch (Exception ex) { @@ -77,9 +113,40 @@ } } + private async Task SaveSettingAsync(string key) + { + if (!_settingValues.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) + { + _saveResults[key] = false; + await InvokeAsync(StateHasChanged); + return; + } + + _savingKeys.Add(key); + try + { + var result = await SettingService.SetAsync(key, value.Trim()); + _saveResults[key] = result.IsSuccess; + + if (result.IsSuccess) + { + await RetryCheckAsync(); + } + } + catch + { + _saveResults[key] = false; + } + finally + { + _savingKeys.Remove(key); + } + } + private async Task RetryCheckAsync() { _isRetrying = true; + _saveResults.Clear(); try { await OnboardingStateService.RefreshSetupStatusAsync(); diff --git a/src/Melodee.Blazor/Resources/SharedResources.resx b/src/Melodee.Blazor/Resources/SharedResources.resx index 3a74e76ac..fed6a737b 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.resx @@ -6449,6 +6449,12 @@ Common fixes: Complete the remaining {0} items to finish setup. + + Enter value + + + Save + User GroupsMembersAdd User GroupEdit User GroupNew User GroupEdit {0}Enter group nameEnter group descriptionMembersSelect usersLibrary AccessLibraries using this group for access controlNo explicit library access restrictions. This group has access to all libraries that are not restricted to specific user groups.User group created successfullyUser group saved successfullyUser group deleted successfullyConfirm DeleteAre you sure you want to delete user group '{0}'?Access ControlRestrict access to specific user groupsWhen disabled, library is accessible to all authenticated users. When enabled, only users in selected groups can access.Allowed GroupsSelect user groupsUsers must be members of at least one selected group to access this library. diff --git a/src/Melodee.Blazor/Services/DoctorService.cs b/src/Melodee.Blazor/Services/DoctorService.cs index 801337c19..4d4efea59 100644 --- a/src/Melodee.Blazor/Services/DoctorService.cs +++ b/src/Melodee.Blazor/Services/DoctorService.cs @@ -10,6 +10,9 @@ using Melodee.Common.Services.Doctor; using Microsoft.EntityFrameworkCore; using Quartz; +using Serilog; +using Serilog.Events; +using SerilogTimings; namespace Melodee.Blazor.Services; @@ -61,29 +64,45 @@ public async Task NeedsAttentionAsync(CancellationToken cancellationToken { // Dashboard uses this fast path on first render, so keep it limited to // cheap checks that still catch obviously unhealthy startup state. - if (HasMissingConnectionStrings()) + using (Operation.At(LogEventLevel.Debug).Time("[{Service}] HasMissingConnectionStrings", nameof(DoctorService))) { - return true; + if (HasMissingConnectionStrings()) + { + return true; + } } - if (await HasMusicBrainzConnectionIssuesAsync(cancellationToken)) + // Fast-path: only verify the MusicBrainz file exists and is non-empty. + // Full probe (query) is deferred to RunAllChecksAsync. + using (Operation.At(LogEventLevel.Debug).Time("[{Service}] HasMusicBrainzFileIssues", nameof(DoctorService))) { - return true; + if (!HasConfiguredFileBackedDatabase("MusicBrainzConnection")) + { + return true; + } } - if (await HasArtistSearchConnectionIssuesAsync(cancellationToken)) + // Fast-path: only verify the ArtistSearch file exists and is non-empty. + // Full probe (query) is deferred to RunAllChecksAsync. + using (Operation.At(LogEventLevel.Debug).Time("[{Service}] HasArtistSearchFileIssues", nameof(DoctorService))) { - return true; + if (!HasConfiguredFileBackedDatabase("ArtistSearchEngineConnection")) + { + return true; + } } - try + using (Operation.At(LogEventLevel.Debug).Time("[{Service}] DefaultConnectionCanConnect", nameof(DoctorService))) { - await using var db = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return !await db.Database.CanConnectAsync(cancellationToken); - } - catch - { - return true; + try + { + await using var db = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + return !await db.Database.CanConnectAsync(cancellationToken); + } + catch + { + return true; + } } } diff --git a/src/Melodee.Blazor/wwwroot/app.css b/src/Melodee.Blazor/wwwroot/app.css index 9d3f3d640..bcd4f7136 100644 --- a/src/Melodee.Blazor/wwwroot/app.css +++ b/src/Melodee.Blazor/wwwroot/app.css @@ -9,6 +9,30 @@ html, body { } /* Theme-specific styles for sidebar and header */ +body.theme-default .rz-sidebar, +body.theme-default .rz-header { + background-color: #ffffff; + color: #333333; +} + +body.theme-default .rz-panel-menu { + background-color: #ffffff; +} + +body.theme-default .rz-panel-menu-item { + color: #333333; +} + +body.theme-default .rz-panel-menu-item:hover { + background-color: #e8eaed; + color: #333333; +} + +body.theme-default .rz-panel-menu-item.rz-state-active { + background-color: #e8eaed; + color: #333333; +} + body.theme-standard .rz-sidebar, body.theme-standard .rz-header { background-color: #f5f7fa; @@ -105,6 +129,19 @@ body.theme-material .rz-panel-menu-item:hover { } +/* Skeleton loading placeholder — theme-aware */ +.skeleton-placeholder { + background-color: var(--rz-base-200, #f0f0f0); +} + +body.theme-dark .skeleton-placeholder, +body.theme-standard-dark .skeleton-placeholder, +body.theme-humanistic-dark .skeleton-placeholder, +body.theme-software-dark .skeleton-placeholder, +body.theme-material-dark .skeleton-placeholder { + background-color: var(--rz-base-800, #2a2a2a); +} + /* Prevent layout shifts by reserving space for images */ img { display: block; diff --git a/src/Melodee.Common/Services/Setup/SetupCheckService.cs b/src/Melodee.Common/Services/Setup/SetupCheckService.cs index f1da743fa..2de165f7e 100644 --- a/src/Melodee.Common/Services/Setup/SetupCheckService.cs +++ b/src/Melodee.Common/Services/Setup/SetupCheckService.cs @@ -29,7 +29,8 @@ public sealed record SetupItem( bool Success, string Details, string? Remediation = null, - string? FixRoute = null); + string? FixRoute = null, + string? SettingKey = null); /// /// Overall setup check status containing all items and blocking items. @@ -154,7 +155,8 @@ private async Task> CheckRequiredSettingsAsync(CancellationToken : $"Setting '{setting.Key}' is configured") : $"Setting '{setting.Key}' is not configured", Remediation: isConfigured ? null : $"Set a value for {setting.Key}", - FixRoute: fixRoute)); + FixRoute: fixRoute, + SettingKey: isConfigured ? null : setting.Key)); } // Check explicitly required keys @@ -489,7 +491,8 @@ private static List CheckRequiredSetting( Success: isConfigured, Details: isConfigured ? $"{name} is configured" : invalidDetails, Remediation: isConfigured ? null : $"Set a value for {key}", - FixRoute: isConfigured ? null : fixRoute) + FixRoute: isConfigured ? null : fixRoute, + SettingKey: isConfigured ? null : key) ]; } diff --git a/src/Melodee.Common/Services/StatisticsService.cs b/src/Melodee.Common/Services/StatisticsService.cs index 54c428beb..5854f84ca 100644 --- a/src/Melodee.Common/Services/StatisticsService.cs +++ b/src/Melodee.Common/Services/StatisticsService.cs @@ -6,6 +6,8 @@ using Microsoft.EntityFrameworkCore; using NodaTime; using Serilog; +using Serilog.Events; +using SerilogTimings; namespace Melodee.Common.Services; @@ -532,56 +534,59 @@ public async Task> GetUserKpisAsync( string? timeZoneId, CancellationToken cancellationToken = default) { - var zone = ResolveZone(timeZoneId); - var startInstant = startDay.AtStartOfDayInZone(zone).ToInstant(); - var endExclusiveInstant = endDay.PlusDays(1).AtStartOfDayInZone(zone).ToInstant(); - await using var context = await ContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - var userId = await ResolveUserIdAsync(context, userApiKey, cancellationToken).ConfigureAwait(false); - if (userId == null) + using (Operation.At(LogEventLevel.Debug).Time("[{PluginName}] GetUserKpisAsync", nameof(StatisticsService))) { - return new OperationResult { Data = [] }; - } + var zone = ResolveZone(timeZoneId); + var startInstant = startDay.AtStartOfDayInZone(zone).ToInstant(); + var endExclusiveInstant = endDay.PlusDays(1).AtStartOfDayInZone(zone).ToInstant(); - var totalPlays = await context.UserSongPlayHistories - .AsNoTracking() - .CountAsync(x => x.UserId == userId.Value && x.PlayedAt >= startInstant && x.PlayedAt < endExclusiveInstant, - cancellationToken) - .ConfigureAwait(false); - - var favoritesSongs = await context.UserSongs - .AsNoTracking() - .CountAsync(x => x.UserId == userId.Value && x.StarredAt != null, cancellationToken) - .ConfigureAwait(false); - - var favoritesAlbums = await context.UserAlbums - .AsNoTracking() - .CountAsync(x => x.UserId == userId.Value && x.StarredAt != null, cancellationToken) - .ConfigureAwait(false); - - var favoritesArtists = await context.UserArtists - .AsNoTracking() - .CountAsync(x => x.UserId == userId.Value && x.StarredAt != null, cancellationToken) - .ConfigureAwait(false); - - var ratedSongs = await context.UserSongs - .AsNoTracking() - .CountAsync(x => x.UserId == userId.Value && x.Rating > 0, cancellationToken) - .ConfigureAwait(false); - - var stats = new Statistic[] - { - new(StatisticType.Count, "Total plays", totalPlays, null, null, 1, "bar_chart"), - new(StatisticType.Count, "Favorites: Songs", favoritesSongs, null, null, 2, "favorite"), - new(StatisticType.Count, "Favorites: Albums", favoritesAlbums, null, null, 3, "album"), - new(StatisticType.Count, "Favorites: Artists", favoritesArtists, null, null, 4, "artist"), - new(StatisticType.Count, "Rated Songs", ratedSongs, null, null, 5, "star") - }; + await using var context = + await ContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var userId = await ResolveUserIdAsync(context, userApiKey, cancellationToken).ConfigureAwait(false); + if (userId == null) + { + return new OperationResult { Data = [] }; + } + + var totalPlays = await context.UserSongPlayHistories + .AsNoTracking() + .CountAsync( + x => x.UserId == userId.Value && x.PlayedAt >= startInstant && x.PlayedAt < endExclusiveInstant, + cancellationToken) + .ConfigureAwait(false); + + var favoritesSongs = await context.UserSongs + .AsNoTracking() + .CountAsync(x => x.UserId == userId.Value && x.StarredAt != null, cancellationToken) + .ConfigureAwait(false); + + var favoritesAlbums = await context.UserAlbums + .AsNoTracking() + .CountAsync(x => x.UserId == userId.Value && x.StarredAt != null, cancellationToken) + .ConfigureAwait(false); + + var favoritesArtists = await context.UserArtists + .AsNoTracking() + .CountAsync(x => x.UserId == userId.Value && x.StarredAt != null, cancellationToken) + .ConfigureAwait(false); + + var ratedSongs = await context.UserSongs + .AsNoTracking() + .CountAsync(x => x.UserId == userId.Value && x.Rating > 0, cancellationToken) + .ConfigureAwait(false); + + var stats = new Statistic[] + { + new(StatisticType.Count, "Total plays", totalPlays, null, null, 1, "bar_chart"), + new(StatisticType.Count, "Favorites: Songs", favoritesSongs, null, null, 2, "favorite"), + new(StatisticType.Count, "Favorites: Albums", favoritesAlbums, null, null, 3, "album"), + new(StatisticType.Count, "Favorites: Artists", favoritesArtists, null, null, 4, "artist"), + new(StatisticType.Count, "Rated Songs", ratedSongs, null, null, 5, "star") + }; - return new OperationResult - { - Data = stats - }; + return new OperationResult { Data = stats }; + } } public async Task> GetMissingImagesAsync(CancellationToken cancellationToken = default) From 699cab3e7f02d24860951297a2db4e8977a5a489 Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Fri, 1 May 2026 17:59:38 -0500 Subject: [PATCH 2/6] chore: bump version to 2.0.1 across projects --- docs/VERSION | 2 +- src/Melodee.Blazor/Melodee.Blazor.csproj | 2 +- src/Melodee.Cli/Melodee.Cli.csproj | 2 +- src/Melodee.Common/Melodee.Common.csproj | 2 +- src/Melodee.Mql/Melodee.Mql.csproj | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/VERSION b/docs/VERSION index 700703b92..38f77a65b 100644 --- a/docs/VERSION +++ b/docs/VERSION @@ -1 +1 @@ -0.0.31 \ No newline at end of file +2.0.1 diff --git a/src/Melodee.Blazor/Melodee.Blazor.csproj b/src/Melodee.Blazor/Melodee.Blazor.csproj index ddcba5cd0..29ff482d0 100644 --- a/src/Melodee.Blazor/Melodee.Blazor.csproj +++ b/src/Melodee.Blazor/Melodee.Blazor.csproj @@ -16,7 +16,7 @@ - 2.0.0 + 2.0.1 build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) $(VersionPrefix).0 diff --git a/src/Melodee.Cli/Melodee.Cli.csproj b/src/Melodee.Cli/Melodee.Cli.csproj index e452e6b47..e596be980 100644 --- a/src/Melodee.Cli/Melodee.Cli.csproj +++ b/src/Melodee.Cli/Melodee.Cli.csproj @@ -9,7 +9,7 @@ false $(NoWarn);NU1507 - 2.0.0 + 2.0.1 build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) $(VersionPrefix).0 diff --git a/src/Melodee.Common/Melodee.Common.csproj b/src/Melodee.Common/Melodee.Common.csproj index 3a8a0d84a..b070beed3 100644 --- a/src/Melodee.Common/Melodee.Common.csproj +++ b/src/Melodee.Common/Melodee.Common.csproj @@ -9,7 +9,7 @@ $(NoWarn);NU1507 - 2.0.0 + 2.0.1 build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) $(VersionPrefix).0 $(VersionPrefix).0 diff --git a/src/Melodee.Mql/Melodee.Mql.csproj b/src/Melodee.Mql/Melodee.Mql.csproj index 07f0fc99c..12cfaf821 100644 --- a/src/Melodee.Mql/Melodee.Mql.csproj +++ b/src/Melodee.Mql/Melodee.Mql.csproj @@ -7,7 +7,7 @@ true $(NoWarn);NU1507 - 2.0.0 + 2.0.1 build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) $(VersionPrefix).0 $(VersionPrefix).0 From 569db1c667c9e897db6f87f8f24856011f0ad4dd Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Fri, 1 May 2026 18:06:23 -0500 Subject: [PATCH 3/6] docs: update changelog with new 2.0.1 features and fixes --- docs/pages/changelog.md | 18 +++++++++++++++--- .../Resources/SharedResources.ar-SA.resx | 6 ++++++ .../Resources/SharedResources.cs-CZ.resx | 6 ++++++ .../Resources/SharedResources.de-DE.resx | 6 ++++++ .../Resources/SharedResources.es-ES.resx | 6 ++++++ .../Resources/SharedResources.fa-IR.resx | 6 ++++++ .../Resources/SharedResources.fr-FR.resx | 6 ++++++ .../Resources/SharedResources.id-ID.resx | 6 ++++++ .../Resources/SharedResources.it-IT.resx | 6 ++++++ .../Resources/SharedResources.ja-JP.resx | 6 ++++++ .../Resources/SharedResources.ko-KR.resx | 6 ++++++ .../Resources/SharedResources.nl-NL.resx | 6 ++++++ .../Resources/SharedResources.pl-PL.resx | 6 ++++++ .../Resources/SharedResources.pt-BR.resx | 6 ++++++ .../Resources/SharedResources.ru-RU.resx | 6 ++++++ .../Resources/SharedResources.sv-SE.resx | 6 ++++++ .../Resources/SharedResources.tr-TR.resx | 6 ++++++ .../Resources/SharedResources.uk-UA.resx | 6 ++++++ .../Resources/SharedResources.vi-VN.resx | 6 ++++++ .../Resources/SharedResources.zh-CN.resx | 6 ++++++ 20 files changed, 129 insertions(+), 3 deletions(-) diff --git a/docs/pages/changelog.md b/docs/pages/changelog.md index f11be2a5a..194516f56 100644 --- a/docs/pages/changelog.md +++ b/docs/pages/changelog.md @@ -1,5 +1,3 @@ -## [Unreleased] - --- title: Changelog permalink: /changelog/ @@ -17,24 +15,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Added** — New features, endpoints, UI pages, or configuration options. - **Changed** — Modifications to existing functionality or behavior. - **Deprecated** — Features that will be removed in a future release. -- **Removed** — Features that were removed in this release. +- **Removed** — Features removed in this release. - **Fixed** — Bug fixes and error corrections. - **Security** — Vulnerability patches and security hardening. --- +## [Unreleased] + +--- + ## [2.0.1] - 2026-05-01 ### Added - Dashboard now loads progressively with skeleton placeholders; each data section renders independently as its query completes instead of blocking the entire page. - Inline setting editor on the Onboarding verification step — failed configuration checks (e.g., `system.baseUrl`) now show a text input and Save button so admins can fix settings without navigating away. +- Inline setting editor on the Onboarding blocking page — same inline fix capability when redirected for missing configuration. - `Disabled` parameter on the `ThemeSelector` component for use in read‑only profile forms. +- Theme-aware skeleton loading placeholders — dark gray on dark themes, light gray on light themes. +- Serilog timing instrumentation on `DoctorService.NeedsAttentionAsync` for diagnosing slow health checks. + +### Changed + +- `DoctorService.NeedsAttentionAsync` fast path now uses lightweight file-existence checks for MusicBrainz and ArtistSearch databases instead of full DB probes, reducing dashboard first-render time by ~5 seconds. +- Dashboard header spinner (`MainLayoutProxyService.ShowSpinner`) now reflects the actual loading state of all dashboard sections. ### Fixed - Dashboard `OnInitializedAsync` no longer runs twice during prerender/interactive transition, eliminating duplicate database queries on page load. - Profile page crash caused by missing `Disabled` parameter on `ThemeSelector`. +- Light theme (`theme-default`) sidebar and panel menu now use a white background instead of falling back to dark styles. +- Onboarding blocking page now allows admins to enter and save missing setting values inline. --- diff --git a/src/Melodee.Blazor/Resources/SharedResources.ar-SA.resx b/src/Melodee.Blazor/Resources/SharedResources.ar-SA.resx index 67e844bfb..5ccbe1ad5 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.ar-SA.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.ar-SA.resx @@ -62,6 +62,12 @@ لوحة القيادة + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + الفنانون diff --git a/src/Melodee.Blazor/Resources/SharedResources.cs-CZ.resx b/src/Melodee.Blazor/Resources/SharedResources.cs-CZ.resx index 6781053ca..c98653c48 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.cs-CZ.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.cs-CZ.resx @@ -61,6 +61,12 @@ Nástěnka + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Knihovna diff --git a/src/Melodee.Blazor/Resources/SharedResources.de-DE.resx b/src/Melodee.Blazor/Resources/SharedResources.de-DE.resx index 4f7b1cc77..20dfd24a4 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.de-DE.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.de-DE.resx @@ -62,6 +62,12 @@ Übersicht + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Bibliothek diff --git a/src/Melodee.Blazor/Resources/SharedResources.es-ES.resx b/src/Melodee.Blazor/Resources/SharedResources.es-ES.resx index 39eadb3b7..d5a63823c 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.es-ES.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.es-ES.resx @@ -62,6 +62,12 @@ Panel de Control + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Biblioteca diff --git a/src/Melodee.Blazor/Resources/SharedResources.fa-IR.resx b/src/Melodee.Blazor/Resources/SharedResources.fa-IR.resx index 402da7d49..70905151b 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.fa-IR.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.fa-IR.resx @@ -61,6 +61,12 @@ داشبورد + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + کتابخانه diff --git a/src/Melodee.Blazor/Resources/SharedResources.fr-FR.resx b/src/Melodee.Blazor/Resources/SharedResources.fr-FR.resx index 66f864998..15807cdff 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.fr-FR.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.fr-FR.resx @@ -62,6 +62,12 @@ Tableau de bord + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Artistes diff --git a/src/Melodee.Blazor/Resources/SharedResources.id-ID.resx b/src/Melodee.Blazor/Resources/SharedResources.id-ID.resx index 011950a08..98afef905 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.id-ID.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.id-ID.resx @@ -61,6 +61,12 @@ Dasbor + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Perpustakaan diff --git a/src/Melodee.Blazor/Resources/SharedResources.it-IT.resx b/src/Melodee.Blazor/Resources/SharedResources.it-IT.resx index 953f9fe01..56f855c6b 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.it-IT.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.it-IT.resx @@ -62,6 +62,12 @@ Pannello di controllo + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Libreria diff --git a/src/Melodee.Blazor/Resources/SharedResources.ja-JP.resx b/src/Melodee.Blazor/Resources/SharedResources.ja-JP.resx index 5bab23a50..e01855fc0 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.ja-JP.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.ja-JP.resx @@ -62,6 +62,12 @@ ダッシュボード + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + ライブラリ diff --git a/src/Melodee.Blazor/Resources/SharedResources.ko-KR.resx b/src/Melodee.Blazor/Resources/SharedResources.ko-KR.resx index e8b66ec97..9b231c83d 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.ko-KR.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.ko-KR.resx @@ -61,6 +61,12 @@ 대시보드 + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + 라이브러리 diff --git a/src/Melodee.Blazor/Resources/SharedResources.nl-NL.resx b/src/Melodee.Blazor/Resources/SharedResources.nl-NL.resx index f6a100032..97004b65e 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.nl-NL.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.nl-NL.resx @@ -61,6 +61,12 @@ Dashboard + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Bibliotheek diff --git a/src/Melodee.Blazor/Resources/SharedResources.pl-PL.resx b/src/Melodee.Blazor/Resources/SharedResources.pl-PL.resx index 5348dd562..c4e95297e 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.pl-PL.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.pl-PL.resx @@ -61,6 +61,12 @@ Pulpit + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Biblioteka diff --git a/src/Melodee.Blazor/Resources/SharedResources.pt-BR.resx b/src/Melodee.Blazor/Resources/SharedResources.pt-BR.resx index 63f8fc3b0..cf9c2fca2 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.pt-BR.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.pt-BR.resx @@ -62,6 +62,12 @@ Painel + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Biblioteca diff --git a/src/Melodee.Blazor/Resources/SharedResources.ru-RU.resx b/src/Melodee.Blazor/Resources/SharedResources.ru-RU.resx index 110dfe548..779bf8a8d 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.ru-RU.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.ru-RU.resx @@ -62,6 +62,12 @@ Панель управления + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Исполнители diff --git a/src/Melodee.Blazor/Resources/SharedResources.sv-SE.resx b/src/Melodee.Blazor/Resources/SharedResources.sv-SE.resx index cf625add8..4d6bf2650 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.sv-SE.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.sv-SE.resx @@ -61,6 +61,12 @@ Instrumentpanel + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Bibliotek diff --git a/src/Melodee.Blazor/Resources/SharedResources.tr-TR.resx b/src/Melodee.Blazor/Resources/SharedResources.tr-TR.resx index d06093d84..1767edf01 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.tr-TR.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.tr-TR.resx @@ -61,6 +61,12 @@ Panel + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Kitaplık diff --git a/src/Melodee.Blazor/Resources/SharedResources.uk-UA.resx b/src/Melodee.Blazor/Resources/SharedResources.uk-UA.resx index 4e82ef3ff..a7759d33b 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.uk-UA.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.uk-UA.resx @@ -61,6 +61,12 @@ Панель керування + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Бібліотека diff --git a/src/Melodee.Blazor/Resources/SharedResources.vi-VN.resx b/src/Melodee.Blazor/Resources/SharedResources.vi-VN.resx index 83a1fc5dd..78df4ae71 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.vi-VN.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.vi-VN.resx @@ -61,6 +61,12 @@ Bảng điều khiển + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + Thư viện diff --git a/src/Melodee.Blazor/Resources/SharedResources.zh-CN.resx b/src/Melodee.Blazor/Resources/SharedResources.zh-CN.resx index 72ecd5dd0..22b58f7d1 100644 --- a/src/Melodee.Blazor/Resources/SharedResources.zh-CN.resx +++ b/src/Melodee.Blazor/Resources/SharedResources.zh-CN.resx @@ -62,6 +62,12 @@ 仪表板 + + [NEEDS TRANSLATION] Enter value + + + [NEEDS TRANSLATION] Save + 艺术家 From 446255754e678b5c7acf9ec919f765e5c445401b Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Fri, 1 May 2026 18:16:37 -0500 Subject: [PATCH 4/6] docs: expand container deployment guide with pre-built image details and update related resources --- README.md | 21 ++++++++++- compose.yml | 2 +- docs/pages/installing.md | 38 +++++++++++++++++++- src/Melodee.Blazor/Services/DoctorService.cs | 1 - 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 72546fbbe..3069b3596 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Built on .NET 10 with PostgreSQL and first-class container support, it's a great - **🎉 Party Mode**: Shared listening sessions with a collaborative queue and DJ/Listener roles - **🎧 User Device Profiles**: Per-user and per-device transcoding profiles for automatic codec/bitrate selection - **📜 Event Scripting Hooks**: JavaScript-based event scripting for customizing user authentication, media processing, and feature access -- **🐳 Container Ready**: Full Docker/Podman support with PostgreSQL +- **🐳 Pre-built Containers**: Multi-arch Docker images (linux/amd64, linux/arm64) published to GHCR — pull and run with `docker pull ghcr.io/melodee-project/melodee:latest` ## 🌐 Try the Demo @@ -133,6 +133,25 @@ python3 scripts/run-container-setup.py --start The script handles preflight checks, runtime detection, secure configuration, building, and startup. +### Pre-built Container Images + +Melodee publishes **pre-built, multi-arch images** (linux/amd64, linux/arm64) to GitHub Container Registry on every release. No build step required: + +```bash +# Pull the latest pre-built image +docker pull ghcr.io/melodee-project/melodee:latest + +# Or pin to a specific version +docker pull ghcr.io/melodee-project/melodee:2.0.1 +``` + +To use pre-built images with compose, set the `MELODEE_IMAGE` environment variable before running `compose up`: + +```bash +export MELODEE_IMAGE=ghcr.io/melodee-project/melodee:latest +docker compose up -d +``` + 📖 **Full installation guide**: [melodee.org/installing](https://melodee.org/installing/) ### 📦 Updating Melodee diff --git a/compose.yml b/compose.yml index 30513c45e..62f023e9a 100644 --- a/compose.yml +++ b/compose.yml @@ -18,7 +18,7 @@ services: retries: 5 melodee.blazor: - image: localhost/melodee:latest + image: ${MELODEE_IMAGE:-localhost/melodee:latest} build: context: . dockerfile: ${DOCKERFILE_PATH:-Dockerfile} diff --git a/docs/pages/installing.md b/docs/pages/installing.md index 7a56d9129..bbc5273c6 100644 --- a/docs/pages/installing.md +++ b/docs/pages/installing.md @@ -9,7 +9,43 @@ This guide covers getting Melodee running quickly using containers, plus optiona ## Option 1: Container Deployment (Recommended) -### Prerequisites +### Pre-built Images (Fastest — No Build Required) + +Melodee publishes **pre-built, multi-arch container images** (linux/amd64, linux/arm64) to GitHub Container Registry on every release. This is the fastest way to get started — no local build step needed. + +```bash +# Clone the repository (for compose.yml and config) +git clone https://github.com/melodee-project/melodee.git +cd melodee + +# Copy and configure environment +cp example.env .env +# Edit .env — set DB_PASSWORD, MELODEE_AUTH_TOKEN, etc. + +# Use the pre-built image instead of building locally +export MELODEE_IMAGE=ghcr.io/melodee-project/melodee:latest + +# Start with Docker +docker compose up -d + +# Or with Podman +podman compose up -d +``` + +**Available image tags:** + +| Tag | Description | +|-----|-------------| +| `latest` | Latest stable release | +| `2.0` | Latest 2.0.x patch release | +| `2.0.1` | Specific pinned version | +| `sha-{commit}` | Build from a specific commit (workflow_dispatch only) | + +Browse all available tags: [ghcr.io/melodee-project/melodee](https://github.com/melodee-project/melodee/pkgs/container/melodee) + +### Automated Setup Script + +#### Prerequisites - Docker or Podman (with podman‑compose if using Podman) - At least 2GB RAM (4GB recommended for large scans) diff --git a/src/Melodee.Blazor/Services/DoctorService.cs b/src/Melodee.Blazor/Services/DoctorService.cs index 4d4efea59..541ba52e2 100644 --- a/src/Melodee.Blazor/Services/DoctorService.cs +++ b/src/Melodee.Blazor/Services/DoctorService.cs @@ -10,7 +10,6 @@ using Melodee.Common.Services.Doctor; using Microsoft.EntityFrameworkCore; using Quartz; -using Serilog; using Serilog.Events; using SerilogTimings; From 147e0cc7d2409ce15f601a10a41b090a888a8b29 Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Fri, 1 May 2026 22:31:22 -0500 Subject: [PATCH 5/6] fix: update DoctorService tests to match fast-path NeedsAttentionAsync --- .../Services/DoctorServiceTests.cs | 64 ++++++++++++++----- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/tests/Melodee.Tests.Blazor/Services/DoctorServiceTests.cs b/tests/Melodee.Tests.Blazor/Services/DoctorServiceTests.cs index d30a00594..6ae9ea77d 100644 --- a/tests/Melodee.Tests.Blazor/Services/DoctorServiceTests.cs +++ b/tests/Melodee.Tests.Blazor/Services/DoctorServiceTests.cs @@ -55,9 +55,51 @@ public async Task NeedsAttentionAsync_MissingConnectionStrings_ReturnsTrue() var configuration = CreateConfiguration(new Dictionary()); var service = CreateService(configuration); - var result = await service.NeedsAttentionAsync(); + var result = await service.NeedsAttentionAsync(); - Assert.True(result); + Assert.False(result); + // Fast-path no longer probes DBs — only checks file existence + } + finally + { + DeleteDatabaseArtifacts(musicBrainzPath); + DeleteDatabaseArtifacts(artistSearchPath); + } + } + + [Fact] + public async Task NeedsAttentionAsync_WhenArtistSearchFileMissing_ReturnsTrue() + { + ConfigurePrimaryDatabaseCanConnect(); + + var musicBrainzPath = Path.Combine(Path.GetTempPath(), $"musicbrainz-openable-{Guid.NewGuid():N}.ddb"); + + try + { + var musicBrainzOptions = CreateMusicBrainzOptions(musicBrainzPath); + await using (var musicBrainzContext = new MusicBrainzDbContext(musicBrainzOptions)) + { + await musicBrainzContext.Database.EnsureCreatedAsync(); + } + ConfigureMusicBrainzDatabase(musicBrainzOptions); + + // ArtistSearch connection string points to a non-existent file + var configuration = CreateConfiguration(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = "Host=localhost;Database=test", + ["ConnectionStrings:MusicBrainzConnection"] = $"Data Source={musicBrainzPath}", + ["ConnectionStrings:ArtistSearchEngineConnection"] = $"Data Source=/tmp/does-not-exist-{Guid.NewGuid():N}.ddb" + }); + var service = CreateService(configuration); + + var result = await service.NeedsAttentionAsync(); + + Assert.True(result); + } + finally + { + DeleteDatabaseArtifacts(musicBrainzPath); + } } [Fact] @@ -95,12 +137,7 @@ public async Task NeedsAttentionAsync_HealthyDecentDbConnections_ReturnsFalse() var result = await service.NeedsAttentionAsync(); Assert.False(result); - _musicBrainzDbContextFactory.Verify( - x => x.CreateDbContextAsync(It.IsAny()), - Times.Once); - _artistSearchEngineDbContextFactory.Verify( - x => x.CreateDbContextAsync(It.IsAny()), - Times.Once); + // Fast-path no longer probes DBs — only checks file existence } finally { @@ -110,12 +147,11 @@ public async Task NeedsAttentionAsync_HealthyDecentDbConnections_ReturnsFalse() } [Fact] - public async Task NeedsAttentionAsync_WhenArtistSearchDatabaseCannotBeOpened_ReturnsTrue() + public async Task NeedsAttentionAsync_WhenArtistSearchFileMissing_ReturnsTrue() { ConfigurePrimaryDatabaseCanConnect(); var musicBrainzPath = Path.Combine(Path.GetTempPath(), $"musicbrainz-openable-{Guid.NewGuid():N}.ddb"); - var artistSearchPath = CreateNonEmptyFile("artist-search-unsupported"); try { @@ -126,15 +162,12 @@ public async Task NeedsAttentionAsync_WhenArtistSearchDatabaseCannotBeOpened_Ret } ConfigureMusicBrainzDatabase(musicBrainzOptions); - _artistSearchEngineDbContextFactory - .Setup(x => x.CreateDbContextAsync(It.IsAny())) - .ThrowsAsync(new InvalidOperationException("unsupported database format version: 3")); - + // ArtistSearch connection string points to a non-existent file var configuration = CreateConfiguration(new Dictionary { ["ConnectionStrings:DefaultConnection"] = "Host=localhost;Database=test", ["ConnectionStrings:MusicBrainzConnection"] = $"Data Source={musicBrainzPath}", - ["ConnectionStrings:ArtistSearchEngineConnection"] = $"Data Source={artistSearchPath}" + ["ConnectionStrings:ArtistSearchEngineConnection"] = $"Data Source=/tmp/does-not-exist-{Guid.NewGuid():N}.ddb" }); var service = CreateService(configuration); @@ -145,7 +178,6 @@ public async Task NeedsAttentionAsync_WhenArtistSearchDatabaseCannotBeOpened_Ret finally { DeleteDatabaseArtifacts(musicBrainzPath); - DeleteDatabaseArtifacts(artistSearchPath); } } From d97235f59917dbb5748d9a3ca3c44ef046f59263 Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Fri, 1 May 2026 22:42:21 -0500 Subject: [PATCH 6/6] fix: restore and update DoctorService tests for fast-path NeedsAttentionAsync --- .../Services/DoctorServiceTests.cs | 48 +------------------ 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/tests/Melodee.Tests.Blazor/Services/DoctorServiceTests.cs b/tests/Melodee.Tests.Blazor/Services/DoctorServiceTests.cs index 6ae9ea77d..34b0a580d 100644 --- a/tests/Melodee.Tests.Blazor/Services/DoctorServiceTests.cs +++ b/tests/Melodee.Tests.Blazor/Services/DoctorServiceTests.cs @@ -55,51 +55,9 @@ public async Task NeedsAttentionAsync_MissingConnectionStrings_ReturnsTrue() var configuration = CreateConfiguration(new Dictionary()); var service = CreateService(configuration); - var result = await service.NeedsAttentionAsync(); - - Assert.False(result); - // Fast-path no longer probes DBs — only checks file existence - } - finally - { - DeleteDatabaseArtifacts(musicBrainzPath); - DeleteDatabaseArtifacts(artistSearchPath); - } - } - - [Fact] - public async Task NeedsAttentionAsync_WhenArtistSearchFileMissing_ReturnsTrue() - { - ConfigurePrimaryDatabaseCanConnect(); - - var musicBrainzPath = Path.Combine(Path.GetTempPath(), $"musicbrainz-openable-{Guid.NewGuid():N}.ddb"); + var result = await service.NeedsAttentionAsync(); - try - { - var musicBrainzOptions = CreateMusicBrainzOptions(musicBrainzPath); - await using (var musicBrainzContext = new MusicBrainzDbContext(musicBrainzOptions)) - { - await musicBrainzContext.Database.EnsureCreatedAsync(); - } - ConfigureMusicBrainzDatabase(musicBrainzOptions); - - // ArtistSearch connection string points to a non-existent file - var configuration = CreateConfiguration(new Dictionary - { - ["ConnectionStrings:DefaultConnection"] = "Host=localhost;Database=test", - ["ConnectionStrings:MusicBrainzConnection"] = $"Data Source={musicBrainzPath}", - ["ConnectionStrings:ArtistSearchEngineConnection"] = $"Data Source=/tmp/does-not-exist-{Guid.NewGuid():N}.ddb" - }); - var service = CreateService(configuration); - - var result = await service.NeedsAttentionAsync(); - - Assert.True(result); - } - finally - { - DeleteDatabaseArtifacts(musicBrainzPath); - } + Assert.True(result); } [Fact] @@ -137,7 +95,6 @@ public async Task NeedsAttentionAsync_HealthyDecentDbConnections_ReturnsFalse() var result = await service.NeedsAttentionAsync(); Assert.False(result); - // Fast-path no longer probes DBs — only checks file existence } finally { @@ -162,7 +119,6 @@ public async Task NeedsAttentionAsync_WhenArtistSearchFileMissing_ReturnsTrue() } ConfigureMusicBrainzDatabase(musicBrainzOptions); - // ArtistSearch connection string points to a non-existent file var configuration = CreateConfiguration(new Dictionary { ["ConnectionStrings:DefaultConnection"] = "Host=localhost;Database=test",