diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index faa06a7f7..9b1f943ea 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -39,7 +39,6 @@ jobs:
echo 'To fix this check, run "make format" and commit the changes'
exit 1
fi
-
go:
name: Test Go code
runs-on: ubuntu-latest
@@ -156,6 +155,51 @@ jobs:
!dist/*.zip
retention-days: 7
+ msi:
+ name: Build and publish MSI files
+ needs: [binaries]
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: binaries
+ path: dist
+
+ - name: Build MSI
+ run: |
+ sudo apt-get install -y wixl jq
+
+ NAVIDROME_BUILD_VERSION=$(jq -r '.version' < $GITHUB_WORKSPACE/dist/metadata.json | sed -e 's/^v//' -e 's/-SNAPSHOT/.1/' )
+
+ mkdir -p $GITHUB_WORKSPACE/wix/386
+ cp $GITHUB_WORKSPACE/LICENSE $GITHUB_WORKSPACE/wix/386
+ cp $GITHUB_WORKSPACE/README.md $GITHUB_WORKSPACE/wix/386
+
+ cp -r $GITHUB_WORKSPACE/wix/386 $GITHUB_WORKSPACE/wix/amd64
+
+ cp $GITHUB_WORKSPACE/dist/navidrome_windows_386_windows_386/navidrome.exe $GITHUB_WORKSPACE/wix/386
+ cp $GITHUB_WORKSPACE/dist/navidrome_windows_amd64_windows_amd64_v1/navidrome.exe $GITHUB_WORKSPACE/wix/amd64
+
+ # workaround for wixl WixVariable not working to override bmp locations
+ sudo cp $GITHUB_WORKSPACE/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
+ sudo cp $GITHUB_WORKSPACE/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
+
+ cd $GITHUB_WORKSPACE/wix/386
+ wixl ../navidrome.wxs -D Version=$NAVIDROME_BUILD_VERSION -D Platform=x86 --arch x86 --ext ui --output ../navidrome_386.msi
+
+ cd $GITHUB_WORKSPACE/wix/amd64
+ wixl ../navidrome.wxs -D Version=$NAVIDROME_BUILD_VERSION -D Platform=x64 --arch x64 --ext ui --output ../navidrome_amd64.msi
+
+ ls -la $GITHUB_WORKSPACE/wix/*.msi
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: installers
+ path: wix/*.msi
+ retention-days: 7
+
docker:
name: Build and publish Docker images
needs: [binaries]
diff --git a/cmd/root.go b/cmd/root.go
index b6aa0d708..f623b408f 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -2,7 +2,6 @@ package cmd
import (
"context"
- "fmt"
"os"
"os/signal"
"strings"
@@ -37,7 +36,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
preRun()
},
Run: func(cmd *cobra.Command, args []string) {
- runNavidrome()
+ runNavidrome(cmd.Context())
},
PostRun: func(cmd *cobra.Command, args []string) {
postRun()
@@ -50,8 +49,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
func Execute() {
rootCmd.SetVersionTemplate(`{{println .Version}}`)
if err := rootCmd.Execute(); err != nil {
- fmt.Println(err)
- os.Exit(1)
+ log.Fatal(err)
}
}
@@ -69,10 +67,10 @@ func postRun() {
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
// it will cancel the context and exit gracefully.
-func runNavidrome() {
+func runNavidrome(ctx context.Context) {
defer db.Init()()
- ctx, cancel := mainContext()
+ ctx, cancel := mainContext(ctx)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
@@ -88,8 +86,8 @@ func runNavidrome() {
}
// mainContext returns a context that is cancelled when the process receives a signal to exit.
-func mainContext() (context.Context, context.CancelFunc) {
- return signal.NotifyContext(context.Background(),
+func mainContext(ctx context.Context) (context.Context, context.CancelFunc) {
+ return signal.NotifyContext(ctx,
os.Interrupt,
syscall.SIGHUP,
syscall.SIGTERM,
diff --git a/cmd/svc.go b/cmd/svc.go
new file mode 100644
index 000000000..21c9b64cc
--- /dev/null
+++ b/cmd/svc.go
@@ -0,0 +1,209 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/kardianos/service"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/log"
+ "github.com/spf13/cobra"
+)
+
+var (
+ svcStatusLabels = map[service.Status]string{
+ service.StatusUnknown: "Unknown",
+ service.StatusStopped: "Stopped",
+ service.StatusRunning: "Running",
+ }
+)
+
+func init() {
+ svcCmd.AddCommand(buildInstallCmd())
+ svcCmd.AddCommand(buildUninstallCmd())
+ svcCmd.AddCommand(buildStartCmd())
+ svcCmd.AddCommand(buildStopCmd())
+ svcCmd.AddCommand(buildStatusCmd())
+ svcCmd.AddCommand(buildExecuteCmd())
+ rootCmd.AddCommand(svcCmd)
+}
+
+var svcCmd = &cobra.Command{
+ Use: "service",
+ Aliases: []string{"svc"},
+ Short: "Manage Navidrome as a service",
+ Long: fmt.Sprintf("Manage Navidrome as a service, using the OS service manager (%s)", service.Platform()),
+ Run: runServiceCmd,
+}
+
+type svcControl struct {
+ ctx context.Context
+ cancel context.CancelFunc
+ done chan struct{}
+}
+
+func (p *svcControl) Start(service.Service) error {
+ p.done = make(chan struct{})
+ p.ctx, p.cancel = context.WithCancel(context.Background())
+ go func() {
+ runNavidrome(p.ctx)
+ close(p.done)
+ }()
+ return nil
+}
+
+func (p *svcControl) Stop(service.Service) error {
+ log.Info("Stopping service")
+ p.cancel()
+ select {
+ case <-p.done:
+ log.Info("Service stopped gracefully")
+ case <-time.After(10 * time.Second):
+ log.Error("Service did not stop in time. Killing it.")
+ }
+ return nil
+}
+
+var svcInstance = sync.OnceValue(func() service.Service {
+ options := make(service.KeyValue)
+ options["Restart"] = "on-success"
+ options["SuccessExitStatus"] = "1 2 8 SIGKILL"
+ options["UserService"] = false
+ options["LogDirectory"] = conf.Server.DataFolder
+ svcConfig := &service.Config{
+ Name: "navidrome",
+ DisplayName: "Navidrome",
+ Description: "Your Personal Streaming Service",
+ Dependencies: []string{
+ "Requires=",
+ "After="},
+ WorkingDirectory: executablePath(),
+ Option: options,
+ }
+ arguments := []string{"service", "execute"}
+ if conf.Server.ConfigFile != "" {
+ arguments = append(arguments, "-c", conf.Server.ConfigFile)
+ }
+ svcConfig.Arguments = arguments
+
+ prg := &svcControl{}
+ svc, err := service.New(prg, svcConfig)
+ if err != nil {
+ log.Fatal(err)
+ }
+ return svc
+})
+
+func runServiceCmd(cmd *cobra.Command, _ []string) {
+ _ = cmd.Help()
+}
+
+func executablePath() string {
+ ex, err := os.Executable()
+ if err != nil {
+ log.Fatal(err)
+ }
+ return filepath.Dir(ex)
+}
+
+func buildInstallCmd() *cobra.Command {
+ runInstallCmd := func(_ *cobra.Command, _ []string) {
+ var err error
+ println("Installing service with:")
+ println(" working directory: " + executablePath())
+ println(" music folder: " + conf.Server.MusicFolder)
+ println(" data folder: " + conf.Server.DataFolder)
+ println(" logs folder: " + conf.Server.DataFolder)
+ if cfgFile != "" {
+ conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
+ if err != nil {
+ log.Fatal(err)
+ }
+ println(" config file: " + conf.Server.ConfigFile)
+ }
+ err = svcInstance().Install()
+ if err != nil {
+ log.Fatal(err)
+ }
+ println("Service installed. Use 'navidrome svc start' to start it.")
+ }
+
+ return &cobra.Command{
+ Use: "install",
+ Short: "Install Navidrome service.",
+ Run: runInstallCmd,
+ }
+}
+
+func buildUninstallCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "uninstall",
+ Short: "Uninstall Navidrome service. Does not delete the music or data folders",
+ Run: func(cmd *cobra.Command, args []string) {
+ err := svcInstance().Uninstall()
+ if err != nil {
+ log.Fatal(err)
+ }
+ println("Service uninstalled. Music and data folders are still intact.")
+ },
+ }
+}
+
+func buildStartCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "start",
+ Short: "Start Navidrome service",
+ Run: func(cmd *cobra.Command, args []string) {
+ err := svcInstance().Start()
+ if err != nil {
+ log.Fatal(err)
+ }
+ println("Service started. Use 'navidrome svc status' to check its status.")
+ },
+ }
+}
+
+func buildStopCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "stop",
+ Short: "Stop Navidrome service",
+ Run: func(cmd *cobra.Command, args []string) {
+ err := svcInstance().Stop()
+ if err != nil {
+ log.Fatal(err)
+ }
+ println("Service stopped. Use 'navidrome svc status' to check its status.")
+ },
+ }
+}
+
+func buildStatusCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "status",
+ Short: "Show Navidrome service status",
+ Run: func(cmd *cobra.Command, args []string) {
+ status, err := svcInstance().Status()
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("Navidrome is %s.\n", svcStatusLabels[status])
+ },
+ }
+}
+
+func buildExecuteCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "execute",
+ Short: "Run navidrome as a service in the foreground (it is very unlikely you want to run this, you are better off running just navidrome)",
+ Run: func(cmd *cobra.Command, args []string) {
+ err := svcInstance().Run()
+ if err != nil {
+ log.Fatal(err)
+ }
+ },
+ }
+}
diff --git a/go.mod b/go.mod
index 44a90c477..396e96e88 100644
--- a/go.mod
+++ b/go.mod
@@ -27,6 +27,7 @@ require (
github.com/google/wire v0.6.0
github.com/hashicorp/go-multierror v1.1.1
github.com/jellydator/ttlcache/v3 v3.3.0
+ github.com/kardianos/service v1.2.2
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.1
github.com/matoous/go-nanoid/v2 v2.1.0
diff --git a/go.sum b/go.sum
index 1413e2529..c1a05298c 100644
--- a/go.sum
+++ b/go.sum
@@ -96,6 +96,8 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
+github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -255,6 +257,7 @@ golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/wix/Navidrome_UI_Flow.wxs b/wix/Navidrome_UI_Flow.wxs
new file mode 100644
index 000000000..2ea38e172
--- /dev/null
+++ b/wix/Navidrome_UI_Flow.wxs
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wix/SettingsDlg.wxs b/wix/SettingsDlg.wxs
new file mode 100644
index 000000000..4a83f91da
--- /dev/null
+++ b/wix/SettingsDlg.wxs
@@ -0,0 +1,44 @@
+
+
+
+
+
+
diff --git a/wix/bmp/banner.bmp b/wix/bmp/banner.bmp
new file mode 100644
index 000000000..bbaa0fc2b
Binary files /dev/null and b/wix/bmp/banner.bmp differ
diff --git a/wix/bmp/dialogue.bmp b/wix/bmp/dialogue.bmp
new file mode 100644
index 000000000..56f3db55b
Binary files /dev/null and b/wix/bmp/dialogue.bmp differ
diff --git a/wix/convertIniToToml.vbs b/wix/convertIniToToml.vbs
new file mode 100644
index 000000000..1feb7d6d5
--- /dev/null
+++ b/wix/convertIniToToml.vbs
@@ -0,0 +1,17 @@
+Const ForReading = 1
+Const ForWriting = 2
+
+sSourceFilename = Wscript.Arguments(0)
+sTargetFilename = Wscript.Arguments(1)
+
+Set oFSO = CreateObject("Scripting.FileSystemObject")
+Set oFile = oFSO.OpenTextFile(sSourceFilename, ForReading)
+sFileContent = oFile.ReadAll
+oFile.Close
+
+sNewFileContent = Replace(sFileContent, "[MSI_PLACEHOLDER_SECTION]" & vbCrLf, "")
+If Not ( oFSO.FileExists(sTargetFilename) ) Then
+ Set oFile = oFSO.CreateTextFile(sTargetFilename)
+ oFile.Write sNewFileContent
+ oFile.Close
+End If
diff --git a/wix/navidrome.wxs b/wix/navidrome.wxs
new file mode 100644
index 000000000..ad923a0a4
--- /dev/null
+++ b/wix/navidrome.wxs
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Not Installed AND NOT WIX_UPGRADE_DETECTED
+
+
+
+ NOT Installed AND NOT REMOVE
+
+
+
+
+
+
+
+
+
+
+