Compare commits

...

1 commit

Author SHA1 Message Date
Philip Peterson
7a1b0e6072 Quintodrome
Some checks failed
CI / ci (push) Has been cancelled
2026-09-02 23:41:24 -08:00
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: schedule:
interval: weekly interval: weekly
open-pull-requests-limit: 10 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 - package-ecosystem: docker
directory: "/" directory: "/"
schedule: 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 name: Package/Release
needs: [build, msi] needs: [build, msi]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
outputs: outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }} package_list: ${{ steps.set-package-list.outputs.package_list }}
steps: steps:

2
.gitignore vendored
View file

@ -40,3 +40,5 @@ openspec/
.agents .agents
go.work* go.work*
.worktrees/ .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) ### 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 # v1.9.0
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50 ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
######################################################################################################################## ########################################################################################################################
### Build Navidrome UI ### 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 WORKDIR /app
# Install node dependencies # 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) ### 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 / / COPY --from=xx / /
ARG TARGETPLATFORM ARG TARGETPLATFORM
@ -82,7 +82,7 @@ EOT
######################################################################################################################## ########################################################################################################################
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled) ### 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 RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / / COPY --from=xx / /
WORKDIR /workspace WORKDIR /workspace
@ -139,7 +139,7 @@ COPY --from=build /out /
######################################################################################################################## ########################################################################################################################
### Build Final Image ### 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 maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" 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)."

171
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) A [Tauri](https://tauri.app) desktop app that wraps
[![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) [Navidrome](https://www.navidrome.org) — your self-hosted music server — into a
[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest) native macOS/Windows application.
[![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)
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your The server is bundled as a sidecar binary and managed automatically, so there's
music collection from any browser or mobile device. It's like your personal Spotify! 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. ## What it does
Please use [releases](https://github.com/navidrome/navidrome/releases) instead of
the `master` branch in order to get a stable set of binaries.
## [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, 1. Checks whether a Navidrome server is already listening on `127.0.0.1:4533`
please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our (via its `/ping` health endpoint). If so, it reuses it as-is.
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way 2. Otherwise it spawns the bundled `navidrome` binary as a **separate sidecar
([ui/backend dev](https://www.navidrome.org/docs/developers/), process**, pointed at the OS app-data/music directories, and waits for it to
[translations](https://www.navidrome.org/docs/developers/translations/), become ready.
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our 3. Loads the Navidrome UI in the content webview, below a thin toolbar with
[Discord server](https://discord.gg/xh7j7yF). 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 ```sh
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods). # one-time: Node >= 24 (see .nvmrc), Go >= 1.26, Rust
A share of the revenue helps fund the development of Navidrome at no additional cost for you. 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 To wipe Navidrome's persistent state (database, cache) and start fresh:
- Handles very **large music collections** ```sh
- Streams virtually **any audio format** available just clean-state
- 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**
## Translations ## Building a distributable
Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking ```sh
for [more contributors](https://www.navidrome.org/docs/developers/translations/) just bundle # or: ./desktop/scripts/build.sh
```
<a href="https://poeditor.com/"> This produces the platform's packaged app under
<img height="32" src="https://github.com/user-attachments/assets/c19b1d2b-01e1-4682-a007-12356c42147c"> `desktop/src-tauri/target/release/bundle/` (e.g. `macos/Quintodrome.app` on
</a> 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.
## Documentation > Artifacts are unsigned (no Apple/Windows code-signing certs), so users will
All documentation can be found in the project's website: https://www.navidrome.org/docs. > see Gatekeeper/SmartScreen prompts.
Here are some useful direct links:
- [Overview](https://www.navidrome.org/docs/overview/) ## Configuration
- [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/)
## Screenshots Environment variables override the defaults:
<p align="left"> | Variable | Default | Purpose |
<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"> | `QUINTODROME_HOST` | `127.0.0.1` | Address the webview connects to |
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-album-view.png"> | `QUINTODROME_PORT` | `4533` | Port for the Navidrome server |
<img width="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-desktop-player.png"> | `QUINTODROME_MUSIC_FOLDER` | OS music dir (e.g. `~/Music`) | Where your music lives |
</p> | `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 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] }) rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
var err error 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)) timesShuffled = make([]time.Time, len(timesDecreasingChronologically))
copy(timesShuffled, timesDecreasingChronologically) copy(timesShuffled, timesDecreasingChronologically)
//nolint:gosec // test-only shuffle of backup times, no cryptographic requirement
rand.Shuffle(len(timesShuffled), func(i, j int) { rand.Shuffle(len(timesShuffled), func(i, j int) {
timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i] 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 - darwin_arm64
- linux_386 - linux_386
- linux_amd64 - linux_amd64
- linux_arm_v5 - linux_arm_5
- linux_arm_v6 - linux_arm_6
- linux_arm_v7 - linux_arm_7
- linux_arm64 - linux_arm64
- linux_riscv64 - linux_riscv64
- windows_386 - windows_386

View file

@ -9,7 +9,9 @@ mkdir -p "$(dirname "$output")"
# Build the source folder name based on GOOS, GOARCH and GOARM. # Build the source folder name based on GOOS, GOARCH and GOARM.
source="${GOOS}_${GOARCH}" source="${GOOS}_${GOARCH}"
if [ "$GOARCH" = "arm" ]; then 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 fi
# Copy the output to the desired location # Copy the output to the desired location