Quintodrome
Some checks are pending
CI / ci (push) Waiting to run

This commit is contained in:
Philip Peterson 2026-09-02 23:41:18 -08:00
parent 3b958dd6a7
commit 7a1b0e6072
48 changed files with 7020 additions and 77 deletions

154
.dagger/main.go Normal file
View file

@ -0,0 +1,154 @@
package main
import (
"context"
"dagger/quintodrome/internal/dagger"
)
type Quintodrome struct{}
// Ci runs the full Linux CI: lint, build, and test for both Go and JS.
func (m *Quintodrome) Ci(ctx context.Context,
// +defaultPath="."
src *dagger.Directory,
) error {
if err := m.LintGo(ctx, src); err != nil {
return err
}
if err := m.FmtGo(ctx, src); err != nil {
return err
}
if err := m.BuildGo(ctx, src); err != nil {
return err
}
if err := m.TestGo(ctx, src); err != nil {
return err
}
if err := m.LintJS(ctx, src); err != nil {
return err
}
if err := m.BuildJS(ctx, src); err != nil {
return err
}
if err := m.TestJS(ctx, src); err != nil {
return err
}
return nil
}
// LintGo runs golangci-lint with the repo's .golangci.yml.
func (m *Quintodrome) LintGo(ctx context.Context, src *dagger.Directory) error {
_, err := goContainer().
WithDirectory("/repo", src).
WithWorkdir("/repo").
// Fetch the release tarball directly instead of the install script,
// whose hardcoded SHA256 for v2.12.0 doesn't match the re-uploaded
// release tarball.
WithExec([]string{"sh", "-c", "curl -sSfL https://github.com/golangci/golangci-lint/releases/download/v2.12.0/golangci-lint-2.12.0-linux-amd64.tar.gz | tar -xz --strip-components=1 -C /usr/local/bin"}).
WithExec([]string{"golangci-lint", "run", "--timeout", "2m"}).
Sync(ctx)
return err
}
// FmtGo checks that goimports and go mod tidy leave the tree clean.
func (m *Quintodrome) FmtGo(ctx context.Context, src *dagger.Directory) error {
_, err := goContainer().
WithDirectory("/repo", src).
WithWorkdir("/repo").
WithExec([]string{"sh", "-c", `
go run golang.org/x/tools/cmd/goimports@latest -w $(find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$')
go mod tidy
test -z "$(git status --porcelain)"
`}).
Sync(ctx)
return err
}
// BuildGo compiles the Navidrome server and all packages.
func (m *Quintodrome) BuildGo(ctx context.Context, src *dagger.Directory) error {
_, err := goContainer().
WithDirectory("/repo", src).
WithWorkdir("/repo").
WithExec([]string{"go", "build", "-tags", "netgo,sqlite_fts5", "./..."}).
Sync(ctx)
return err
}
// TestGo runs the Go test suite.
func (m *Quintodrome) TestGo(ctx context.Context, src *dagger.Directory) error {
_, err := goContainer().
WithDirectory("/repo", src).
WithWorkdir("/repo").
WithExec([]string{"go", "test", "-tags", "netgo,sqlite_fts5", "./..."}).
Sync(ctx)
return err
}
// LintJS runs prettier and eslint in the web UI.
func (m *Quintodrome) LintJS(ctx context.Context, src *dagger.Directory) error {
_, err := jsContainer().
WithDirectory("/repo", src).
WithWorkdir("/repo/ui").
WithExec([]string{"npm", "ci", "--ignore-scripts"}).
WithExec([]string{"npm", "run", "check-formatting"}).
WithExec([]string{"npm", "run", "lint"}).
Sync(ctx)
return err
}
// BuildJS builds the Navidrome web UI.
func (m *Quintodrome) BuildJS(ctx context.Context, src *dagger.Directory) error {
_, err := jsContainer().
WithDirectory("/repo", src).
WithWorkdir("/repo/ui").
WithExec([]string{"npm", "ci", "--ignore-scripts"}).
WithExec([]string{"npm", "run", "build"}).
Sync(ctx)
return err
}
// TestJS runs the JS test suite.
func (m *Quintodrome) TestJS(ctx context.Context, src *dagger.Directory) error {
_, err := jsContainer().
WithDirectory("/repo", src).
WithWorkdir("/repo/ui").
WithExec([]string{"npm", "ci", "--ignore-scripts"}).
WithExec([]string{"npm", "test"}).
Sync(ctx)
return err
}
func goContainer() *dagger.Container {
return dag.Container().
From("golang:1.26").
WithExec([]string{"apt-get", "update"}).
WithExec([]string{"apt-get", "install", "-y", "zip"})
}
func jsContainer() *dagger.Container {
return dag.Container().From("node:24")
}
// BuildDesktopWindows builds the Windows desktop app (.exe/.msi) inside a
// Windows container.
//
// Experimental: this requires the Dagger engine to run on a Windows host with
// BuildKit's Windows container support enabled (the engine uses the containerd
// worker with `platforms = ["windows/amd64"]`). Unverified end-to-end — the
// Rust MSVC toolchain and Tauri's NSIS/WiX bundling may not behave in a
// Windows Server Core container the way they do on a full Windows runner.
func (m *Quintodrome) BuildDesktopWindows(ctx context.Context, src *dagger.Directory) *dagger.Directory {
setup := `Set-ExecutionPolicy Bypass -Scope Process -Force
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
iex ((New-Object Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
choco install -y nodejs-lts golang rustup.install visualstudio2022buildtools visualstudio2022-workload-vctools --no-progress`
return dag.Container().
From("mcr.microsoft.com/windows/servercore:ltsc2022").
WithDirectory("C:/src", src).
WithWorkdir("C:/src").
WithExec([]string{"powershell", "-NoProfile", "-Command", setup}).
WithExec([]string{"cmd", "/c", "npm --version && go version && rustc --version"}).
Directory("C:/src/desktop/src-tauri/target/release/bundle")
}

26
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,26 @@
name: CI
on:
push:
branches: [master]
tags: ["v*"]
pull_request:
branches: [master]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Dagger CLI
run: |
curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR="$HOME/.local/bin" sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Run CI (Dagger)
run: dagger call ci

View file

@ -0,0 +1,174 @@
name: Desktop Release
# Builds the Quintodrome desktop app (Tauri) for macOS (.app/.dmg) and
# Windows (.exe/.msi), bundling the Navidrome server as a sidecar, and
# attaches the artifacts to the Gitea release on `v*` tags.
#
# Note: artifacts are unsigned (no Apple/Windows code-signing certs), so users
# will hit Gatekeeper/SmartScreen prompts.
on:
push:
tags: ["v*"]
workflow_dispatch:
concurrency:
group: desktop-${{ github.ref }}
cancel-in-progress: true
jobs:
build-desktop:
name: Build desktop (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
target: aarch64-apple-darwin
- os: macos-13 # Intel runner
target: x86_64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
workspaces: desktop/src-tauri
# Navidrome's sqlite_fts5 tag needs CGO, so on Windows we need a gcc.
- name: Setup Windows CGO (mingw)
if: runner.os == 'Windows'
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: mingw-w64-x86_64-gcc
update: false
- name: Add mingw to PATH
if: runner.os == 'Windows'
shell: bash
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
- name: Build Navidrome web UI
shell: bash
run: |
cd ui
# --ignore-scripts skips the workbox postinstall (bin/update-workbox.sh),
# a bash script that fails under cmd.exe on Windows. It's only needed
# for the PWA service worker, which the desktop app doesn't use.
npm ci --ignore-scripts
npm run build
- name: Build Navidrome server binary
shell: bash
run: |
GIT_SHA=$(git rev-parse --short HEAD)
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
GIT_TAG="${GITHUB_REF_NAME#v}"
else
GIT_TAG="dev"
fi
if [[ "$RUNNER_OS" == "Windows" ]]; then
export CGO_ENABLED=1
OUTPUT="navidrome.exe"
else
OUTPUT="navidrome"
fi
go build \
-ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$GIT_SHA -X github.com/navidrome/navidrome/consts.gitTag=$GIT_TAG" \
-tags=netgo,sqlite_fts5 \
-o "$OUTPUT"
# Stage the sidecar under the target-triple name tauri expects.
mkdir -p desktop/src-tauri/binaries
if [[ "$RUNNER_OS" == "Windows" ]]; then
cp navidrome.exe "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}.exe"
else
cp navidrome "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}"
chmod +x "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}"
fi
- name: Build Tauri app
shell: bash
run: |
cd desktop
npx --yes @tauri-apps/cli@2 build
- name: Collect artifacts (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
mkdir -p dist
BUNDLE=desktop/src-tauri/target/release/bundle
ditto -c -k --keepParent "$BUNDLE/macos/Quintodrome.app" "dist/Quintodrome-${{ matrix.target }}.app.zip"
cp "$BUNDLE"/dmg/*.dmg dist/ 2>/dev/null || true
ls -la dist
- name: Collect artifacts (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
mkdir -p dist
BUNDLE=desktop/src-tauri/target/release/bundle
cp "$BUNDLE"/nsis/*.exe dist/ 2>/dev/null || true
cp "$BUNDLE"/msi/*.msi dist/ 2>/dev/null || true
ls -la dist
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: desktop-${{ matrix.target }}
path: dist/*
retention-days: 7
release:
name: Publish Gitea release
needs: build-desktop
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
path: dist
pattern: desktop-*
merge-multiple: true
- run: ls -R dist
- name: Create release and upload assets
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME#v}"
RELEASE_ID=$(curl -sS -X POST "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false}" \
| jq -r '.id')
for f in dist/*; do
curl -sS -X POST "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$f"
done

View file

@ -10,6 +10,16 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: cargo
directory: "/desktop/src-tauri"
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: rust-toolchain
directory: "/desktop"
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: docker
directory: "/"
schedule:

26
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: CI
on:
push:
branches: [master]
tags: ["v*"]
pull_request:
branches: [master]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Dagger CLI
run: |
curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR="$HOME/.local/bin" sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Run CI (Dagger)
run: dagger call ci

164
.github/workflows/desktop.yml vendored Normal file
View file

@ -0,0 +1,164 @@
name: Desktop Release
# Builds the Quintodrome desktop app (Tauri) for macOS (.app/.dmg) and
# Windows (.exe/.msi), bundling the Navidrome server as a sidecar, and
# attaches the artifacts to the GitHub release on `v*` tags.
#
# Note: artifacts are unsigned (no Apple/Windows code-signing certs), so users
# will hit Gatekeeper/SmartScreen prompts.
on:
push:
tags: ["v*"]
workflow_dispatch:
concurrency:
group: desktop-${{ github.ref }}
cancel-in-progress: true
jobs:
build-desktop:
name: Build desktop (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
target: aarch64-apple-darwin
- os: macos-13 # Intel runner
target: x86_64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
workspaces: desktop/src-tauri
# Navidrome's sqlite_fts5 tag needs CGO, so on Windows we need a gcc.
- name: Setup Windows CGO (mingw)
if: runner.os == 'Windows'
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: mingw-w64-x86_64-gcc
update: false
- name: Add mingw to PATH
if: runner.os == 'Windows'
shell: bash
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
- name: Build Navidrome web UI
shell: bash
run: |
cd ui
# --ignore-scripts skips the workbox postinstall (bin/update-workbox.sh),
# a bash script that fails under cmd.exe on Windows. It's only needed
# for the PWA service worker, which the desktop app doesn't use.
npm ci --ignore-scripts
npm run build
- name: Build Navidrome server binary
shell: bash
run: |
GIT_SHA=$(git rev-parse --short HEAD)
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
GIT_TAG="${GITHUB_REF_NAME#v}"
else
GIT_TAG="dev"
fi
if [[ "$RUNNER_OS" == "Windows" ]]; then
export CGO_ENABLED=1
OUTPUT="navidrome.exe"
else
OUTPUT="navidrome"
fi
go build \
-ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$GIT_SHA -X github.com/navidrome/navidrome/consts.gitTag=$GIT_TAG" \
-tags=netgo,sqlite_fts5 \
-o "$OUTPUT"
# Stage the sidecar under the target-triple name tauri expects.
mkdir -p desktop/src-tauri/binaries
if [[ "$RUNNER_OS" == "Windows" ]]; then
cp navidrome.exe "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}.exe"
else
cp navidrome "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}"
chmod +x "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}"
fi
- name: Build Tauri app
shell: bash
run: |
cd desktop
npx --yes @tauri-apps/cli@2 build
- name: Collect artifacts (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
mkdir -p dist
BUNDLE=desktop/src-tauri/target/release/bundle
ditto -c -k --keepParent "$BUNDLE/macos/Quintodrome.app" "dist/Quintodrome-${{ matrix.target }}.app.zip"
cp "$BUNDLE"/dmg/*.dmg dist/ 2>/dev/null || true
ls -la dist
- name: Collect artifacts (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
mkdir -p dist
BUNDLE=desktop/src-tauri/target/release/bundle
cp "$BUNDLE"/nsis/*.exe dist/ 2>/dev/null || true
cp "$BUNDLE"/msi/*.msi dist/ 2>/dev/null || true
ls -la dist
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: desktop-${{ matrix.target }}
path: dist/*
retention-days: 7
release:
name: Publish GitHub release
needs: build-desktop
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
path: dist
pattern: desktop-*
merge-multiple: true
- run: ls -R dist
- name: Upload to GitHub release
uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true

View file

@ -468,6 +468,8 @@ jobs:
name: Package/Release
needs: [build, msi]
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:

2
.gitignore vendored
View file

@ -40,3 +40,5 @@ openspec/
.agents
go.work*
.worktrees/
.dagger/internal/
.dagger/querybuilder/

View file

@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
########################################################################################################################
### Build xx (original image: tonistiigi/xx)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
FROM --platform=$BUILDPLATFORM docker.io/library/alpine:3.20 AS xx-build
# v1.9.0
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Build Navidrome UI
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
FROM --platform=$BUILDPLATFORM docker.io/library/node:lts-alpine AS ui
WORKDIR /app
# Install node dependencies
@ -43,7 +43,7 @@ COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine AS build-alpine
COPY --from=xx / /
ARG TARGETPLATFORM
@ -82,7 +82,7 @@ EOT
########################################################################################################################
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-trixie AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
@ -139,7 +139,7 @@ COPY --from=build /out /
########################################################################################################################
### Build Final Image
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
FROM docker.io/library/alpine:3.20 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"

30
Justfile Normal file
View file

@ -0,0 +1,30 @@
# Quintodrome desktop app
# Run the desktop app (builds the Navidrome sidecar, then `cargo run`).
# Requires Go >= 1.26 and Node >= 24 (see .nvmrc) for the sidecar build.
run:
./desktop/scripts/build.sh --skip-tauri
cd desktop/src-tauri && cargo run
# Build the packaged .app (desktop/src-tauri/target/release/bundle/).
bundle:
./desktop/scripts/build.sh
# Build only the Navidrome sidecar binary.
sidecar:
./desktop/scripts/build.sh --skip-tauri
# Remove Navidrome's persistent state (database, cache, backups) for a clean
# first-run. Your music files are NOT touched. Quit the app first.
clean-state:
#!/usr/bin/env bash
set -euo pipefail
case "$(uname -s)" in
Darwin) dir="$HOME/Library/Application Support/com.quintodrome.desktop" ;;
Linux) dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.quintodrome.desktop" ;;
MINGW*|MSYS*|CYGWIN*) dir="${APPDATA:-$HOME/AppData/Roaming}/com.quintodrome.desktop" ;;
*) echo "unsupported OS" >&2; exit 1 ;;
esac
echo "Removing: $dir"
rm -rf "$dir"
echo "Done. Next launch starts fresh (first-run setup)."

173
README.md
View file

@ -1,88 +1,125 @@
<a href="https://www.navidrome.org"><img src="resources/logo-192x192.png" alt="Navidrome logo" title="navidrome" align="right" height="60px" /></a>
<img src="desktop/src-tauri/icons/128x128@2x.png" alt="Quintodrome logo" align="right" height="96" />
# Navidrome Music Server &nbsp;[![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Tired%20of%20paying%20for%20music%20subscriptions%2C%20and%20not%20finding%20what%20you%20really%20like%3F%20Roll%20your%20own%20streaming%20service%21&url=https://navidrome.org&via=navidrome)
# Quintodrome
[![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest)
[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome)
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
[![Gurubase](https://img.shields.io/badge/Gurubase-Ask%20Navidrome%20Guru-006BFF?style=flat-square)](https://gurubase.io/g/navidrome)
A [Tauri](https://tauri.app) desktop app that wraps
[Navidrome](https://www.navidrome.org) — your self-hosted music server — into a
native macOS/Windows application.
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
music collection from any browser or mobile device. It's like your personal Spotify!
The server is bundled as a sidecar binary and managed automatically, so there's
nothing to install, configure, or keep running: launch the app and it starts
(and later shuts down) its own Navidrome instance for you.
> This repository is a fork of [Navidrome](https://github.com/navidrome/navidrome).
> Everything outside `desktop/` is the upstream server + web UI; `desktop/` is
> the Quintodrome wrapper around it.
**Note**: The `master` branch may be in an unstable or even broken state during development.
Please use [releases](https://github.com/navidrome/navidrome/releases) instead of
the `master` branch in order to get a stable set of binaries.
## What it does
## [Check out our Live Demo!](https://www.navidrome.org/demo/)
On launch the app:
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
([ui/backend dev](https://www.navidrome.org/docs/developers/),
[translations](https://www.navidrome.org/docs/developers/translations/),
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
[Discord server](https://discord.gg/xh7j7yF).
1. Checks whether a Navidrome server is already listening on `127.0.0.1:4533`
(via its `/ping` health endpoint). If so, it reuses it as-is.
2. Otherwise it spawns the bundled `navidrome` binary as a **separate sidecar
process**, pointed at the OS app-data/music directories, and waits for it to
become ready.
3. Loads the Navidrome UI in the content webview, below a thin toolbar with
back/forward/reload buttons and an address bar.
4. On exit, sends `SIGTERM` to the spawned server so it shuts down gracefully
(falls back to a hard kill after 3s).
## Installation
Batteries included:
See instructions on the [project's website](https://www.navidrome.org/docs/installation/)
- **Auto-provisioning** — on a fresh install the app creates the initial admin
user (`admin` / `admin`) and logs in as it automatically, skipping both the
"create an admin user" and login screens.
- **Opted-out telemetry** — Navidrome's insights collector is disabled by
default.
- **Masked address bar** — the internal `127.0.0.1:4533` origin is shown as
`http://quintodrome`.
- **External links** (Last.fm, artist pages, …) open in your default browser.
- **Native editing shortcuts** — Cmd+X/C/V/A/Z work in the web UI.
- **Swipe navigation** — two-finger swipe back/forward on macOS.
## Cloud Hosting
## Quick start
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
```sh
# one-time: Node >= 24 (see .nvmrc), Go >= 1.26, Rust
just run
```
[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome)
`just run` builds the Navidrome sidecar and launches the app. No `just`? See
the [desktop README](desktop/README.md) for the plain commands, or install it
with `brew install just`.
## Features
- Handles very **large music collections**
- Streams virtually **any audio format** available
- Reads and uses all your beautifully curated **metadata**
- Great support for **compilations** (Various Artists albums) and **box sets** (multi-disc albums)
- **Multi-user**, each user has their own play counts, playlists, favourites, etc...
- Very **low resource usage**
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
- Ready to use binaries for all major platforms, including **Raspberry Pi**
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
- Translated to **various languages**
To wipe Navidrome's persistent state (database, cache) and start fresh:
## Translations
```sh
just clean-state
```
Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking
for [more contributors](https://www.navidrome.org/docs/developers/translations/)
## Building a distributable
<a href="https://poeditor.com/">
<img height="32" src="https://github.com/user-attachments/assets/c19b1d2b-01e1-4682-a007-12356c42147c">
</a>
```sh
just bundle # or: ./desktop/scripts/build.sh
```
## Documentation
All documentation can be found in the project's website: https://www.navidrome.org/docs.
Here are some useful direct links:
This produces the platform's packaged app under
`desktop/src-tauri/target/release/bundle/` (e.g. `macos/Quintodrome.app` on
macOS). Cross-platform binaries are built in CI — see
[`.github/workflows/desktop.yml`](.github/workflows/desktop.yml), which produces
`.app`/`.dmg` on macOS and `.exe`/`.msi` on Windows for tagged releases.
- [Overview](https://www.navidrome.org/docs/overview/)
- [Installation](https://www.navidrome.org/docs/installation/)
- [Docker](https://www.navidrome.org/docs/installation/docker/)
- [Binaries](https://www.navidrome.org/docs/installation/pre-built-binaries/)
- [Build from source](https://www.navidrome.org/docs/installation/build-from-source/)
- [Development](https://www.navidrome.org/docs/developers/)
- [Subsonic API Compatibility](https://www.navidrome.org/docs/developers/subsonic-api/)
> Artifacts are unsigned (no Apple/Windows code-signing certs), so users will
> see Gatekeeper/SmartScreen prompts.
## Screenshots
## Configuration
<p align="left">
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-login.png">
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-player.png">
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
<img width="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-desktop-player.png">
</p>
Environment variables override the defaults:
| Variable | Default | Purpose |
| --------------------------- | ------------------------------ | ---------------------------------------- |
| `QUINTODROME_HOST` | `127.0.0.1` | Address the webview connects to |
| `QUINTODROME_PORT` | `4533` | Port for the Navidrome server |
| `QUINTODROME_MUSIC_FOLDER` | OS music dir (e.g. `~/Music`) | Where your music lives |
| `QUINTODROME_ADMIN_PASSWORD`| `admin` | Password for the auto-created admin user (fresh installs only) |
| `QUINTODROME_PUBLIC_URL` | `http://quintodrome` | Friendly origin shown in the address bar |
The data folder (database, cache) is always the OS app-data directory
(e.g. `~/Library/Application Support/com.quintodrome.desktop` on macOS).
## Repository layout
```
.
├── desktop/ # Quintodrome: the Tauri wrapper (Rust + toolbar HTML)
│ ├── src-tauri/ # sidecar spawn/health-check, lifecycle, menu, clipboard
│ ├── src/ # toolbar.html (nav bar) + index.html (splash)
│ └── scripts/ # build.sh, icon generator
├── ui/ # Navidrome web UI (React)
├── server/ # Navidrome HTTP server
├── core/ # Navidrome core (scanning, transcoding, …)
├── cmd/ # Navidrome entrypoint / CLI
└── Justfile # run / bundle / sidecar / clean-state
```
For the details of building and running the desktop app directly, see
[`desktop/README.md`](desktop/README.md).
## Development
Prerequisites:
- Rust (stable)
- Node.js ≥ 24 (pinned in `.nvmrc`)
- Go ≥ 1.26 (for building the Navidrome binary)
The Navidrome server is built with `make build`; the wrapper stages it as
`desktop/src-tauri/binaries/navidrome-<target-triple>` and bundles it as a Tauri
sidecar. See [`desktop/scripts/build.sh`](desktop/scripts/build.sh).
## Acknowledgements
Quintodrome is built on [Navidrome](https://github.com/navidrome/navidrome),
an open-source web-based music collection server and streamer. All credit for
the music server and web UI goes to the Navidrome project.

View file

@ -100,6 +100,7 @@ func (pd *Queue) Shuffle() {
backupID = current.ID
}
//nolint:gosec // playback shuffle order doesn't need cryptographic randomness
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
var err error

8
dagger.json Normal file
View file

@ -0,0 +1,8 @@
{
"name": "quintodrome",
"engineVersion": "v0.20.6",
"sdk": {
"source": "go"
},
"source": ".dagger"
}

View file

@ -68,6 +68,7 @@ var _ = Describe("database backups", func() {
timesShuffled = make([]time.Time, len(timesDecreasingChronologically))
copy(timesShuffled, timesDecreasingChronologically)
//nolint:gosec // test-only shuffle of backup times, no cryptographic requirement
rand.Shuffle(len(timesShuffled), func(i, j int) {
timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i]
})

121
desktop/README.md Normal file
View file

@ -0,0 +1,121 @@
# Quintodrome Desktop
A [Tauri](https://tauri.app) desktop wrapper that hosts [Navidrome](https://www.navidrome.org)
as its own web app.
On launch the app:
1. Checks whether a Navidrome server is already listening on `127.0.0.1:4533`
(via its `/ping` health endpoint). If so, it reuses it as-is.
2. Otherwise it spawns the bundled `navidrome` binary as a **separate sidecar
process**, pointed at the OS app-data/music directories, and waits for it to
become ready.
3. Loads the Navidrome UI in the content webview, below a thin toolbar with
back/forward/reload buttons and an address bar.
4. On exit, sends `SIGTERM` to the spawned server so it shuts down gracefully
(falls back to a hard kill after 3s).
On a **fresh install** (no existing Navidrome data) the app also auto-creates
the initial admin user (`admin` / `admin`) behind the scenes, and auto-logs-in
as that user on startup — you skip both Navidrome's "create an admin user"
first-run screen and the login page. Change the password in Navidrome settings
afterwards; override it via `QUINTODROME_ADMIN_PASSWORD`.
## How to launch
From the repo root:
```sh
# 0. one-time: make sure Node >= 24 is active (already pinned in .nvmrc)
nodenv local 24.15.0 # or `nvm use` / ensure `node --version` is v24+
# 1. build the Navidrome server + stage it as the sidecar
./desktop/scripts/build.sh --skip-tauri
# 2. launch the app
cd desktop/src-tauri && cargo run
```
A window opens on the splash screen, the app starts Navidrome (or reuses a
running one), then loads the UI. Navidrome is reachable at
<http://127.0.0.1:4533> for the first-run admin setup.
To build a standalone `.app` instead:
```sh
./desktop/scripts/build.sh
# → desktop/src-tauri/target/release/bundle/macos/Quintodrome.app
```
## Layout
```
desktop/
├── src/ # toolbar.html (nav bar) + index.html (loading splash)
├── src-tauri/
│ ├── src/ # Rust: sidecar spawn, health-check, lifecycle, toolbar
│ ├── binaries/ # navidrome-<target-triple> sidecar (built, not committed)
│ ├── icons/ # App icons
│ ├── tauri.conf.json
│ └── Cargo.toml
└── scripts/
├── build.sh # Build navidrome + stage sidecar + tauri build
└── gen_icon.go # Regenerates the source icon (optional)
```
## Prerequisites
- Rust (stable) with the `aarch64-apple-darwin` / `x86_64-apple-darwin` (or
matching host) target.
- Node.js >= 24 (the repo already pins this in `.nvmrc`; via `nodenv` you can
run `nodenv local 24.15.0`).
- Go >= 1.26 (for building the Navidrome binary).
## Build
```sh
./desktop/scripts/build.sh
```
This builds the Navidrome server (`make build`), stages it under
`src-tauri/binaries/navidrome-<target-triple>` (with the executable bit set),
then runs `npx tauri build`. The packaged app lands in
`desktop/src-tauri/target/release/bundle/`.
To build the sidecar without running the full Tauri bundle:
```sh
./desktop/scripts/build.sh --skip-tauri
```
## Run (development)
```sh
# 1. build + stage the sidecar binary
./desktop/scripts/build.sh --skip-tauri
# 2. run the app
cd desktop/src-tauri && cargo run
```
The Rust `build.rs` copies the staged sidecar next to the dev binary
(`target/debug/navidrome`) automatically, so plain `cargo run` works — no
`tauri dev` CLI needed.
## Configuration
Environment variables override the defaults:
| Variable | Default | Purpose |
| --------------------------- | ------------------------ | ------------------------------- |
| `QUINTODROME_HOST` | `127.0.0.1` | Address the webview connects to |
| `QUINTODROME_PORT` | `4533` | Port for the Navidrome server |
| `QUINTODROME_MUSIC_FOLDER` | OS music dir (e.g. `~/Music`) | Where your music lives |
| `QUINTODROME_ADMIN_PASSWORD` | `admin` | Password for the auto-created admin user (fresh installs only) |
| `QUINTODROME_PUBLIC_URL` | `http://quintodrome` | Friendly origin shown in the address bar |
The data folder (DB, cache) is always the OS app-data directory
(e.g. `~/Library/Application Support/com.quintodrome.desktop` on macOS).
> Note: if a Navidrome server is already running on the configured host/port,
> the app simply loads it and does **not** start a second instance.

View file

@ -0,0 +1,2 @@
[toolchain]
channel = "1.98.0"

44
desktop/scripts/build.sh Executable file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Build the Navidrome sidecar binary and package the Quintodrome desktop app.
#
# Usage: ./desktop/scripts/build.sh [--skip-tauri]
#
# The script:
# 1. Builds the Navidrome server for the host platform (via `make build`).
# 2. Copies it into src-tauri/binaries/ under the target-triple name that
# tauri-plugin-shell expects for a sidecar (e.g. navidrome-aarch64-apple-darwin).
# 3. Builds the Tauri application (unless --skip-tauri is passed).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DESKTOP="$ROOT/desktop"
SRC_TAURI="$DESKTOP/src-tauri"
SKIP_TAURI=false
if [[ "${1:-}" == "--skip-tauri" ]]; then
SKIP_TAURI=true
fi
echo "==> Building Navidrome server binary..."
(cd "$ROOT" && make build)
TRIPLE="$(rustc -vV | sed -n 's/^host: //p')"
BIN_NAME="navidrome-$TRIPLE"
case "$TRIPLE" in
*windows*) BIN_NAME="$BIN_NAME.exe" ;;
esac
echo "==> Staging sidecar as binaries/$BIN_NAME"
mkdir -p "$SRC_TAURI/binaries"
cp "$ROOT/navidrome" "$SRC_TAURI/binaries/$BIN_NAME"
chmod +x "$SRC_TAURI/binaries/$BIN_NAME"
if [[ "$SKIP_TAURI" == true ]]; then
echo "==> Skipping Tauri build (--skip-tauri)."
exit 0
fi
echo "==> Building Quintodrome Tauri app..."
(cd "$DESKTOP" && npx --yes @tauri-apps/cli@2 build)
echo "==> Done."

103
desktop/scripts/gen_icon.go Normal file
View file

@ -0,0 +1,103 @@
//go:build ignore
// gen_icon generates a 1024x1024 source icon for the Quintodrome app.
// Run: go run desktop/scripts/gen_icon.go
package main
import (
"image"
"image/color"
"image/png"
"os"
)
const size = 1024
const ss = 4 // supersampling factor per axis
func lerp(a, b, t float64) float64 { return a + (b-a)*t }
type rgb struct{ r, g, b float64 }
func mix(a, b rgb, t float64) rgb {
return rgb{lerp(a.r, b.r, t), lerp(a.g, b.g, t), lerp(a.b, b.b, t)}
}
var (
bgTop = rgb{58, 42, 94}
bgBottom = rgb{23, 21, 28}
circleTop = rgb{168, 85, 247}
circleBottom = rgb{124, 58, 237}
white = rgb{255, 255, 255}
)
func inTriangle(px, py, ax, ay, bx, by, cx, cy float64) bool {
sign := func(x1, y1, x2, y2, x3, y3 float64) float64 {
return (x1-x3)*(y2-y3) - (x2-x3)*(y1-y3)
}
d1 := sign(px, py, ax, ay, bx, by)
d2 := sign(px, py, bx, by, cx, cy)
d3 := sign(px, py, cx, cy, ax, ay)
neg := d1 < 0 || d2 < 0 || d3 < 0
pos := d1 > 0 || d2 > 0 || d3 > 0
return !(neg && pos)
}
func sample(x, y float64) rgb {
// background vertical gradient
c := mix(bgTop, bgBottom, y/size)
// centered circle
cx, cy, cr := float64(size)/2, float64(size)/2, float64(size)*0.31
dx, dy := x-cx, y-cy
if d := dx*dx + dy*dy; d <= cr*cr {
t := (y - (cy - cr)) / (2 * cr)
circle := mix(circleTop, circleBottom, t)
c = circle
}
// play triangle
tx := float64(size) * 0.512
ax, ay := tx-float64(size)*0.145, float64(size)*0.342
bx, by := tx-float64(size)*0.145, float64(size)*0.658
cxv, cyv := tx+float64(size)*0.165, float64(size)*0.5
if inTriangle(x, y, ax, ay, bx, by, cxv, cyv) {
c = white
}
return c
}
func main() {
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
var acc rgb
for sy := 0; sy < ss; sy++ {
for sx := 0; sx < ss; sx++ {
fx := float64(x) + (float64(sx)+0.5)/ss
fy := float64(y) + (float64(sy)+0.5)/ss
s := sample(fx, fy)
acc.r += s.r
acc.g += s.g
acc.b += s.b
}
}
n := float64(ss * ss)
img.SetRGBA(x, y, color.RGBA{
R: uint8(acc.r / n),
G: uint8(acc.g / n),
B: uint8(acc.b / n),
A: 255,
})
}
}
f, err := os.Create(os.Args[len(os.Args)-1])
if err != nil {
panic(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
panic(err)
}
}

4
desktop/src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
binaries/*
!binaries/.gitkeep
target/
gen/

5207
desktop/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,34 @@
[package]
name = "quintodrome"
version = "0.1.0"
description = "Desktop wrapper that hosts Navidrome"
authors = ["Quintodrome"]
edition = "2021"
[lib]
name = "quintodrome_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["unstable"] }
tauri-plugin-shell = "2"
tauri-plugin-opener = "2"
serde_json = "1"
thiserror = "1"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSEvent"] }
objc2-web-kit = { version = "0.3", features = ["WKWebView"] }
block2 = "0.6"
[features]
# This feature is used for production builds or when `devPath` points to the
# filesystem and the built-in dev server is disabled.
custom-protocol = ["tauri/custom-protocol"]

View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for the Quintodrome desktop window",
"windows": ["main"],
"permissions": ["core:default", "core:event:default", "shell:allow-open"],
"remote": {
"urls": ["http://127.0.0.1:4533", "http://localhost:4533"]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1,335 @@
mod server;
use server::{Navidrome, ServerState};
use tauri::{
Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Url, WebviewBuilder, WebviewUrl,
WindowEvent,
};
use tauri_plugin_opener::OpenerExt;
const TOOLBAR_HEIGHT: f64 = 60.0;
const DEFAULT_PUBLIC_URL: &str = "http://quintodrome";
/// Maps the internal Navidrome origin (e.g. `http://127.0.0.1:4533`) to a
/// friendlier, user-facing origin shown in the address bar.
#[derive(Clone)]
struct UrlMask {
real: String,
public: String,
}
impl UrlMask {
fn new(host: &str, port: u16) -> Self {
let real = format!("http://{host}:{port}");
let public = std::env::var("QUINTODROME_PUBLIC_URL")
.unwrap_or_else(|_| DEFAULT_PUBLIC_URL.to_string());
Self { real, public }
}
fn mask(&self, url: &str) -> String {
url.strip_prefix(&self.real)
.map(|rest| format!("{}{}", self.public, rest))
.unwrap_or_else(|| url.to_string())
}
fn unmask(&self, url: &str) -> String {
url.strip_prefix(&self.public)
.map(|rest| format!("{}{}", self.real, rest))
.unwrap_or_else(|| url.to_string())
}
}
#[tauri::command]
fn back(app: tauri::AppHandle) {
if let Some(content) = app.get_webview("content") {
let _ = content.eval("history.back()");
}
}
#[tauri::command]
fn forward(app: tauri::AppHandle) {
if let Some(content) = app.get_webview("content") {
let _ = content.eval("history.forward()");
}
}
#[tauri::command]
fn reload(app: tauri::AppHandle) {
if let Some(content) = app.get_webview("content") {
let _ = content.reload();
}
}
#[tauri::command]
fn navigate(app: tauri::AppHandle, url: String) {
let url = app.state::<UrlMask>().unmask(&url);
if let Some(content) = app.get_webview("content") {
if let Ok(url) = Url::parse(&url) {
let _ = content.navigate(url);
}
}
}
/// Enables two-finger swipe-to-navigate (back/forward) in the content webview.
#[cfg(target_os = "macos")]
fn enable_swipe_navigation(app: &tauri::AppHandle) {
if let Some(content) = app.get_webview("content") {
let _ = content.with_webview(|webview| unsafe {
let view: &objc2_web_kit::WKWebView = &*webview.inner().cast();
view.setAllowsBackForwardNavigationGestures(true);
});
}
}
/// Keeps the address bar in sync with the content webview's real URL.
fn start_url_poller(app: &tauri::AppHandle, mask: UrlMask) {
let handle = app.clone();
std::thread::spawn(move || {
let mut last: Option<String> = None;
loop {
std::thread::sleep(std::time::Duration::from_millis(400));
let Some(content) = handle.get_webview("content") else {
continue;
};
let Ok(url) = content.url() else { continue };
if url.scheme() != "http" && url.scheme() != "https" {
continue;
}
if url.host_str() == Some("tauri.localhost") {
continue;
}
let masked = mask.mask(url.as_str());
if last.as_deref() != Some(masked.as_str()) {
if let Some(toolbar) = handle.get_webview("main") {
let _ = toolbar.emit("url-changed", masked.clone());
}
last = Some(masked);
}
}
});
}
/// Positions the toolbar strip at the top and the content webview below it.
fn layout(app: &tauri::AppHandle) {
let Some(window) = app.get_window("main") else {
return;
};
let Ok(size) = window.inner_size() else {
return;
};
let Ok(scale) = window.scale_factor() else {
return;
};
let width = size.width as f64 / scale;
let height = size.height as f64 / scale;
if let Some(toolbar) = app.get_webview("main") {
let _ = toolbar.set_auto_resize(false);
let _ = toolbar.set_position(LogicalPosition::new(0.0, 0.0));
let _ = toolbar.set_size(LogicalSize::new(width, TOOLBAR_HEIGHT));
}
if let Some(content) = app.get_webview("content") {
let _ = content.set_auto_resize(false);
let _ = content.set_position(LogicalPosition::new(0.0, TOOLBAR_HEIGHT));
let _ = content.set_size(LogicalSize::new(width, (height - TOOLBAR_HEIGHT).max(0.0)));
}
}
/// Installs a local NSEvent monitor that intercepts Cmd+X/C/V/A/Z and
/// dispatches the matching native editing selector to the first responder.
///
/// Menu key equivalents don't reach the webviews (content or devtools) in
/// Tauri on macOS, so we intercept the key events at the app level instead —
/// this is the only reliable way to make cut/copy/paste work in the devtools
/// console.
#[cfg(target_os = "macos")]
fn install_edit_shortcut_monitor(handle: &tauri::AppHandle) {
use objc2::runtime::{AnyObject, Sel};
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEvent, NSEventMask, NSEventModifierFlags};
use std::ptr::NonNull;
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
let handle = handle.clone();
let block = block2::RcBlock::new(move |event: NonNull<NSEvent>| -> *mut NSEvent {
let event = unsafe { event.as_ref() };
let flags = event.modifierFlags();
if !flags.contains(NSEventModifierFlags::Command) {
return event as *const NSEvent as *mut NSEvent;
}
let key = event
.charactersIgnoringModifiers()
.map(|s| s.to_string().to_lowercase())
.unwrap_or_default();
let selector: Option<&'static std::ffi::CStr> = match key.as_str() {
"x" => Some(c"cut:"),
"c" => Some(c"copy:"),
"v" => Some(c"paste:"),
"a" => Some(c"selectAll:"),
"z" => {
if flags.contains(NSEventModifierFlags::Shift) {
Some(c"redo:")
} else {
Some(c"undo:")
}
}
_ => None,
};
let Some(selector) = selector else {
return event as *const NSEvent as *mut NSEvent;
};
unsafe {
let _: bool = app.sendAction_to_from(
Sel::register(selector),
None::<&AnyObject>,
None::<&AnyObject>,
);
}
// selectAll:/undo:/redo: don't dispatch through the responder chain in
// WKWebView, so drive the content webview via execCommand too — but
// only when an editable element is actually focused, otherwise we'd
// select the whole document.
let js = match key.as_str() {
"a" => Some(
"(function(){var e=document.activeElement;if(e&&(e.tagName==='INPUT'||e.tagName==='TEXTAREA'||e.isContentEditable))document.execCommand('selectAll')})()",
),
"z" if flags.contains(NSEventModifierFlags::Shift) => {
Some("document.execCommand('redo')")
}
"z" => Some("document.execCommand('undo')"),
_ => None,
};
if let Some(js) = js {
if let Some(content) = handle.get_webview("content") {
let _ = content.eval(js);
}
}
std::ptr::null_mut()
});
unsafe {
let monitor =
NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask::KeyDown, &block);
if let Some(monitor) = monitor {
std::mem::forget(monitor);
}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
// Native menu so Edit > Cut/Copy/Paste work via clicks.
.menu(|handle| tauri::menu::Menu::default(handle))
.manage(ServerState::default())
.invoke_handler(tauri::generate_handler![back, forward, reload, navigate])
.setup(|app| {
let handle = app.handle().clone();
let host = std::env::var("QUINTODROME_HOST")
.unwrap_or_else(|_| server::DEFAULT_HOST.to_string());
let port = std::env::var("QUINTODROME_PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(server::DEFAULT_PORT);
let navidrome = Navidrome::new(host.clone(), port);
let navidrome_url = navidrome.url.clone();
let mask = UrlMask::new(&host, port);
app.manage(mask.clone());
// The primary webview ("main") is the toolbar; the Navidrome content
// lives in a child webview below it.
let content = WebviewBuilder::new("content", WebviewUrl::App("index.html".into()))
.on_new_window({
let handle = handle.clone();
move |url, _features| {
// Open external links (Last.fm, etc.) in the default
// browser instead of a new in-app window.
if url.scheme() == "http" || url.scheme() == "https" {
let _ = handle.opener().open_url(url.to_string(), None::<&str>);
}
tauri::webview::NewWindowResponse::Deny
}
});
if let Some(window) = app.get_window("main") {
let _ = window.add_child(
content,
LogicalPosition::new(0.0, TOOLBAR_HEIGHT),
LogicalSize::new(800.0, 600.0),
);
let handle = handle.clone();
window.on_window_event(move |event| {
if let WindowEvent::Resized(_) = event {
layout(&handle);
}
});
}
layout(app.handle());
#[cfg(target_os = "macos")]
enable_swipe_navigation(app.handle());
#[cfg(target_os = "macos")]
install_edit_shortcut_monitor(app.handle());
if let Some(toolbar) = app.get_webview("main") {
let _ = toolbar.emit("url-changed", mask.mask(&navidrome_url));
}
start_url_poller(app.handle(), mask);
// Detect/start Navidrome off the main thread, then load it.
let content_url = navidrome_url.clone();
std::thread::spawn(move || {
if let Err(err) = server::ensure_running(&handle, &navidrome) {
eprintln!("quintodrome: {err}");
if let Some(content) = handle.get_webview("content") {
let message = serde_json::to_string(&err.to_string()).unwrap();
let _ = content.eval(&format!(
"document.getElementById('spinner').hidden = true;\
document.getElementById('status').hidden = true;\
var e = document.getElementById('error');\
e.hidden = false;\
e.textContent = 'Failed to start Navidrome: ' + {message};"
));
}
return;
}
// Replace (not push) so the splash page isn't left in history —
// otherwise "back" would return to the loading screen.
if let Some(content) = handle.get_webview("content") {
let js = format!(
"location.replace({})",
serde_json::to_string(&content_url).unwrap()
);
let _ = content.eval(&js);
}
});
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building Quintodrome")
.run(|app, event| {
if let RunEvent::Exit = event {
server::shutdown(&app.state::<ServerState>());
}
});
}

View file

@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
quintodrome_lib::run();
}

View file

@ -0,0 +1,239 @@
use std::{
io::{Read, Write},
net::{TcpStream, ToSocketAddrs},
sync::Mutex,
thread,
time::{Duration, Instant},
};
use tauri::{AppHandle, Manager};
use tauri_plugin_shell::{
process::{CommandChild, CommandEvent},
ShellExt,
};
use thiserror::Error;
pub const DEFAULT_HOST: &str = "127.0.0.1";
pub const DEFAULT_PORT: u16 = 4533;
const READY_TIMEOUT: Duration = Duration::from_secs(60);
const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
const HEALTH_PATH: &str = "/ping";
#[derive(Debug, Error)]
pub enum ServerError {
#[error("could not resolve the application data folder")]
DataFolder,
#[error("could not resolve the music folder")]
MusicFolder,
#[error("failed to spawn navidrome: {0}")]
Spawn(#[from] tauri_plugin_shell::Error),
#[error("navidrome did not become ready within {READY_TIMEOUT:?}")]
Timeout,
}
/// Holds the handle to the Navidrome child process so it can be shut down
/// gracefully when the app exits. It is `None` when Navidrome was already
/// running before the app started.
#[derive(Default)]
pub struct ServerState {
child: Mutex<Option<CommandChild>>,
}
pub struct Navidrome {
pub host: String,
pub port: u16,
pub url: String,
}
impl Navidrome {
pub fn new(host: String, port: u16) -> Self {
let url = format!("http://{host}:{port}");
Self { host, port, url }
}
}
/// Ensures a Navidrome server is reachable at `navidrome.url`. If one is
/// already listening, it is reused as-is. Otherwise a bundled Navidrome binary
/// is spawned as a separate child process and we wait for it to become ready.
pub fn ensure_running(handle: &AppHandle, navidrome: &Navidrome) -> Result<(), ServerError> {
if is_ready(&navidrome.host, navidrome.port) {
eprintln!(
"quintodrome: navidrome is already running at {}",
navidrome.url
);
return Ok(());
}
eprintln!("quintodrome: navidrome is not running, starting it...");
spawn(handle, navidrome)?;
wait_until_ready(&navidrome.host, navidrome.port)?;
eprintln!("quintodrome: navidrome is ready at {}", navidrome.url);
Ok(())
}
fn spawn(handle: &AppHandle, navidrome: &Navidrome) -> Result<(), ServerError> {
let data_folder = handle
.path()
.app_data_dir()
.map_err(|_| ServerError::DataFolder)?;
std::fs::create_dir_all(&data_folder).ok();
let music_folder = match std::env::var_os("QUINTODROME_MUSIC_FOLDER") {
Some(path) if !path.is_empty() => path.into(),
_ => handle
.path()
.audio_dir()
.map_err(|_| ServerError::MusicFolder)?,
};
std::fs::create_dir_all(&music_folder).ok();
let data_folder = data_folder.to_string_lossy().to_string();
let music_folder = music_folder.to_string_lossy().to_string();
let port = navidrome.port.to_string();
let args = vec![
"--nobanner".to_string(),
"--address".to_string(),
navidrome.host.clone(),
"--port".to_string(),
port,
"--datafolder".to_string(),
data_folder,
"--musicfolder".to_string(),
music_folder,
];
// Auto-provision the first admin user on a fresh install. Navidrome only
// honors this during initial setup (when no users/data exist), so an
// existing installation is left untouched.
let admin_password =
std::env::var("QUINTODROME_ADMIN_PASSWORD").unwrap_or_else(|_| "admin".to_string());
let (mut rx, child) = handle
.shell()
.sidecar("navidrome")?
.env(
"ND_DEVAUTOCREATEADMINPASSWORD",
admin_password.as_str(),
)
.env("ND_ENABLEINSIGHTSCOLLECTOR", "false")
.env("ND_DEVAUTOLOGINUSERNAME", "admin")
.args(args)
.spawn()?;
let pid = child.pid();
eprintln!("quintodrome: started navidrome (pid {pid})");
*handle.state::<ServerState>().child.lock().unwrap() = Some(child);
// Forward Navidrome's output to our own stdout/stderr.
tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
print!("{}", String::from_utf8_lossy(&line));
}
CommandEvent::Stderr(line) => {
eprint!("{}", String::from_utf8_lossy(&line));
}
CommandEvent::Terminated(payload) => {
eprintln!(
"quintodrome: navidrome exited with code {:?}",
payload.code
);
}
_ => {}
}
}
});
Ok(())
}
fn wait_until_ready(host: &str, port: u16) -> Result<(), ServerError> {
let deadline = Instant::now() + READY_TIMEOUT;
while Instant::now() < deadline {
if is_ready(host, port) {
return Ok(());
}
thread::sleep(Duration::from_millis(250));
}
Err(ServerError::Timeout)
}
/// Performs a lightweight HTTP health check against Navidrome's `/ping`
/// endpoint. A successful `200` response means the server is up and serving.
fn is_ready(host: &str, port: u16) -> bool {
let addr = match (host, port).to_socket_addrs().ok().and_then(|mut it| it.next()) {
Some(addr) => addr,
None => return false,
};
let mut stream = match TcpStream::connect_timeout(&addr, Duration::from_millis(500)) {
Ok(stream) => stream,
Err(_) => return false,
};
let _ = stream.set_read_timeout(Some(Duration::from_millis(500)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(500)));
let request = format!(
"GET {HEALTH_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"
);
if stream.write_all(request.as_bytes()).is_err() {
return false;
}
let mut response = Vec::new();
let mut buffer = [0u8; 512];
loop {
match stream.read(&mut buffer) {
Ok(0) => break,
Ok(n) => response.extend_from_slice(&buffer[..n]),
Err(_) => break,
}
}
let response = String::from_utf8_lossy(&response);
response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200")
}
/// Sends SIGTERM to the spawned Navidrome process (so it can shut down
/// gracefully) and falls back to a hard kill if it does not exit in time.
pub fn shutdown(state: &ServerState) {
let child = match state.child.lock().unwrap().take() {
Some(child) => child,
None => return,
};
let pid = child.pid();
eprintln!("quintodrome: stopping navidrome (pid {pid})");
#[cfg(unix)]
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
let deadline = Instant::now() + GRACEFUL_SHUTDOWN_TIMEOUT;
while Instant::now() < deadline {
if !process_alive(pid) {
return;
}
thread::sleep(Duration::from_millis(100));
}
if let Err(err) = child.kill() {
eprintln!("quintodrome: failed to kill navidrome: {err}");
}
}
#[cfg(unix)]
fn process_alive(pid: u32) -> bool {
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
#[cfg(not(unix))]
fn process_alive(_pid: u32) -> bool {
true
}

View file

@ -0,0 +1,41 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Quintodrome",
"version": "0.1.0",
"identifier": "com.quintodrome.desktop",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "Quintodrome",
"width": 1280,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true,
"url": "toolbar.html"
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": ["binaries/navidrome"]
}
}

67
desktop/src/index.html Normal file
View file

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quintodrome</title>
<style>
:root {
color-scheme: dark;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #1b1b1f;
color: #e4e4e7;
font-family: system-ui, -apple-system, sans-serif;
text-align: center;
padding: 24px;
}
.container {
max-width: 420px;
}
.spinner {
width: 36px;
height: 36px;
margin: 0 auto 20px;
border: 3px solid rgba(255, 255, 255, 0.15);
border-top-color: #a855f7;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
#status {
color: #a1a1aa;
font-size: 0.95rem;
}
#error {
color: #f87171;
font-size: 0.9rem;
line-height: 1.5;
margin-top: 12px;
word-break: break-word;
}
[hidden] {
display: none;
}
</style>
</head>
<body>
<div class="container">
<div class="spinner" id="spinner"></div>
<p id="status">Starting Navidrome…</p>
<p id="error" hidden></p>
</div>
</body>
</html>

91
desktop/src/toolbar.html Normal file
View file

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quintodrome</title>
<style>
:root {
color-scheme: dark;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
height: 100vh;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
background: #232329;
border-bottom: 1px solid #34343c;
font-family: system-ui, -apple-system, sans-serif;
-webkit-user-select: none;
user-select: none;
overflow: hidden;
}
button {
width: 42px;
height: 42px;
border: none;
border-radius: 8px;
background: transparent;
color: #d4d4d8;
font-size: 22px;
line-height: 1;
cursor: pointer;
flex: none;
}
button:hover {
background: #34343c;
}
button:active {
background: #3f3f46;
}
#url {
flex: 1;
height: 42px;
padding: 0 16px;
border: 1px solid #34343c;
border-radius: 21px;
background: #1b1b1f;
color: #e4e4e7;
font-size: 15px;
outline: none;
min-width: 0;
}
#url:focus {
border-color: #a855f7;
}
</style>
</head>
<body>
<button id="back" title="Back"></button>
<button id="forward" title="Forward"></button>
<button id="reload" title="Reload"></button>
<input id="url" type="text" spellcheck="false" placeholder="http://quintodrome" />
<script>
const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event;
const urlInput = document.getElementById("url");
document.getElementById("back").addEventListener("click", () => invoke("back"));
document.getElementById("forward").addEventListener("click", () => invoke("forward"));
document.getElementById("reload").addEventListener("click", () => invoke("reload"));
urlInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
invoke("navigate", { url: urlInput.value.trim() });
urlInput.blur();
}
});
listen("url-changed", (event) => {
urlInput.value = event.payload;
});
</script>
</body>
</html>

View file

@ -15,9 +15,9 @@ builds:
- darwin_arm64
- linux_386
- linux_amd64
- linux_arm_v5
- linux_arm_v6
- linux_arm_v7
- linux_arm_5
- linux_arm_6
- linux_arm_7
- linux_arm64
- linux_riscv64
- windows_386

View file

@ -9,7 +9,9 @@ mkdir -p "$(dirname "$output")"
# Build the source folder name based on GOOS, GOARCH and GOARM.
source="${GOOS}_${GOARCH}"
if [ "$GOARCH" = "arm" ]; then
source="${source}_${GOARM}"
# GOARM is bare ("5", "6", "7"); the Docker build output uses a "v" prefix
# (e.g. linux_arm_v5), so add it back here to match.
source="${source}_v${GOARM}"
fi
# Copy the output to the desired location