Compare commits

...

10 commits

Author SHA1 Message Date
Philip Peterson
cee31c1e2c try fix build
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
2026-08-25 13:54:27 -08:00
Philip Peterson
5696727a50 try fix cmd-a 2026-08-25 05:32:15 -08:00
Philip Peterson
dc32a2a80f gofmt gen_icon.go 2026-08-25 05:11:19 -08:00
Philip Peterson
51b708f93f try fix cmd-a 2026-08-25 05:10:24 -08:00
Philip Peterson
d1eebbf616 Cleanup 2026-08-25 05:03:07 -08:00
Philip Peterson
01e40c1d54 Fix cut/copy/paste shortcuts via NSEvent monitor 2026-08-25 04:58:17 -08:00
Philip Peterson
5aff975993 Silence golanglint error 2026-08-25 04:49:54 -08:00
Philip Peterson
3c7c02f3e5 Update readme 2026-08-25 04:39:26 -08:00
Philip Peterson
8372aa28f6 Fix devtools console copy 2026-08-25 04:03:31 -08:00
Philip Peterson
9523e65826 fix clipboard 2026-08-25 03:55:38 -08:00
9 changed files with 242 additions and 82 deletions

View file

@ -71,7 +71,10 @@ jobs:
shell: bash
run: |
cd ui
npm ci
# --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

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)
[![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
To wipe Navidrome's persistent state (database, cache) and start fresh:
- 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**
```sh
just clean-state
```
## Translations
## Building a distributable
Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking
for [more contributors](https://www.navidrome.org/docs/developers/translations/)
```sh
just bundle # or: ./desktop/scripts/build.sh
```
<a href="https://poeditor.com/">
<img height="32" src="https://github.com/user-attachments/assets/c19b1d2b-01e1-4682-a007-12356c42147c">
</a>
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.
## Documentation
All documentation can be found in the project's website: https://www.navidrome.org/docs.
Here are some useful direct links:
> Artifacts are unsigned (no Apple/Windows code-signing certs), so users will
> see Gatekeeper/SmartScreen prompts.
- [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/)
## Configuration
## Screenshots
Environment variables override the defaults:
<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>
| 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

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]
})

View file

@ -23,8 +23,8 @@ func mix(a, b rgb, t float64) rgb {
}
var (
bgTop = rgb{58, 42, 94}
bgBottom = rgb{23, 21, 28}
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}
@ -56,7 +56,7 @@ func sample(x, y float64) rgb {
}
// play triangle
tx := float64(size)*0.512
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

View file

@ -2175,9 +2175,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
@ -2197,6 +2205,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-foundation",
]
@ -2257,6 +2266,19 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@ -2718,7 +2740,10 @@ dependencies = [
name = "quintodrome"
version = "0.1.0"
dependencies = [
"block2",
"libc",
"objc2",
"objc2-app-kit",
"objc2-web-kit",
"serde_json",
"tauri",

View file

@ -23,7 +23,10 @@ thiserror = "1"
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

View file

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

View file

@ -26,14 +26,12 @@ impl UrlMask {
Self { real, public }
}
/// `http://127.0.0.1:4533/...` -> `http://quintodrome/...`
fn mask(&self, url: &str) -> String {
url.strip_prefix(&self.real)
.map(|rest| format!("{}{}", self.public, rest))
.unwrap_or_else(|| url.to_string())
}
/// `http://quintodrome/...` -> `http://127.0.0.1:4533/...`
fn unmask(&self, url: &str) -> String {
url.strip_prefix(&self.public)
.map(|rest| format!("{}{}", self.real, rest))
@ -84,10 +82,6 @@ fn enable_swipe_navigation(app: &tauri::AppHandle) {
}
/// Keeps the address bar in sync with the content webview's real URL.
///
/// `on_navigation` only fires for actual document loads, so history traversal
/// (back/forward) and SPA `pushState` navigation would otherwise leave the
/// address bar stale. Polling `Webview::url()` covers all of those.
fn start_url_poller(app: &tauri::AppHandle, mask: UrlMask) {
let handle = app.clone();
std::thread::spawn(move || {
@ -141,13 +135,103 @@ fn layout(app: &tauri::AppHandle) {
}
}
/// 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())
// The default menu wires up the Edit items (undo/cut/copy/paste/select
// all), so Cmd+X/C/V/A/Z work in the webview's text fields.
// 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])
@ -201,6 +285,9 @@ pub fn run() {
#[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));
}