From 1ff5845a0263ccec56a1002a1a62062e93d80ee8 Mon Sep 17 00:00:00 2001 From: sentriz Date: Sat, 18 Apr 2020 19:32:51 +0100 Subject: [PATCH] refactor server startup into jobs --- .golangci.yml | 3 + Dockerfile | 3 +- Dockerfile.dev | 3 +- README.md | 4 +- cmd/gonic/main.go | 35 +++-- go.mod | 1 + go.sum | 3 + scanner/scanner.go | 2 +- server/ctrladmin/ctrl.go | 12 +- server/ctrlbase/ctrl.go | 2 - server/ctrlsubsonic/ctrl.go | 11 +- server/ctrlsubsonic/handlers_common.go | 25 ++-- server/ctrlsubsonic/handlers_raw.go | 2 +- {jukebox => server/jukebox}/jukebox.go | 197 ++++++++++++++++--------- server/server.go | 105 ++++++++----- 15 files changed, 257 insertions(+), 151 deletions(-) rename {jukebox => server/jukebox}/jukebox.go (52%) diff --git a/.golangci.yml b/.golangci.yml index c3e71f1..ea8d952 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,6 @@ +run: + skip-files: + - server/assets/assets_gen.go linters: enable-all: true disable: diff --git a/Dockerfile b/Dockerfile index 7b7481a..af49675 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,8 @@ RUN apk add -U --no-cache \ ca-certificates \ git \ sqlite \ - taglib-dev + taglib-dev \ + alsa-lib-dev WORKDIR /src COPY go.mod . COPY go.sum . diff --git a/Dockerfile.dev b/Dockerfile.dev index fdfceb2..4e5443b 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -6,7 +6,8 @@ RUN apk add -U --no-cache \ ca-certificates \ git \ sqlite \ - taglib-dev + taglib-dev \ + alsa-lib-dev WORKDIR /src COPY . . RUN --mount=type=cache,target=/go/pkg/mod \ diff --git a/README.md b/README.md index e654c27..30c27c4 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ the default login is **admin**/**admin**. password can then be changed from the web interface ``` -$ apt install build-essential git sqlite libtag1-dev ffmpeg # for debian like -$ pacman -S base-devel git sqlite taglib ffmpeg # for arch like +$ apt install build-essential git sqlite libtag1-dev ffmpeg libasound-dev # for debian like +$ pacman -S base-devel git sqlite taglib ffmpeg alsa-lib # for arch like $ go get go.senan.xyz/gonic/cmd/gonic $ export PATH=$PATH:$HOME/go/bin $ gonic -h # or see "configuration options below" diff --git a/cmd/gonic/main.go b/cmd/gonic/main.go index c74890b..cf2b3b6 100644 --- a/cmd/gonic/main.go +++ b/cmd/gonic/main.go @@ -9,6 +9,7 @@ import ( "time" _ "github.com/jinzhu/gorm/dialects/sqlite" + "github.com/oklog/run" "github.com/peterbourgon/ff" "go.senan.xyz/gonic/db" @@ -33,10 +34,16 @@ func main() { ); err != nil { log.Fatalf("error parsing args: %v\n", err) } + // if *showVersion { fmt.Println(version.VERSION) os.Exit(0) } + log.Printf("starting gonic %s\n", version.VERSION) + log.Printf("provided config:\n") + set.VisitAll(func(f *flag.Flag) { + fmt.Printf("\t%s:\t%s\n", f.Name, f.Value) + }) if _, err := os.Stat(*musicPath); os.IsNotExist(err) { log.Fatal("please provide a valid music directory") } @@ -50,20 +57,24 @@ func main() { log.Fatalf("error opening database: %v\n", err) } defer db.Close() + // proxyPrefixExpr := regexp.MustCompile(`^\/*(.*?)\/*$`) *proxyPrefix = proxyPrefixExpr.ReplaceAllString(*proxyPrefix, `/$1`) - serverOptions := server.Options{ - DB: db, - MusicPath: *musicPath, - CachePath: *cachePath, - ListenAddr: *listenAddr, - ScanInterval: time.Duration(*scanInterval) * time.Minute, - ProxyPrefix: *proxyPrefix, + // + server := server.New(server.Options{ + DB: db, + MusicPath: *musicPath, + CachePath: *cachePath, + ProxyPrefix: *proxyPrefix, + }) + var g run.Group + g.Add(server.StartJukebox()) + g.Add(server.StartHTTP(*listenAddr)) + if *scanInterval > 0 { + tickerDur := time.Duration(*scanInterval) * time.Minute + g.Add(server.StartScanTicker(tickerDur)) } - log.Printf("using opts %+v\n", serverOptions) - s := server.New(serverOptions) - log.Printf("starting server at %s", *listenAddr) - if err := s.Start(); err != nil { - log.Fatalf("error starting server: %v\n", err) + if err := g.Run(); err != nil { + log.Printf("error in job: %v", err) } } diff --git a/go.mod b/go.mod index 2b66c0b..e151b07 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/karrick/godirwalk v1.15.2 github.com/kr/pretty v0.1.0 // indirect github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd + github.com/oklog/run v1.1.0 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c github.com/peterbourgon/ff v1.2.0 github.com/pkg/errors v0.8.1 diff --git a/go.sum b/go.sum index 0190c57..abe54e8 100644 --- a/go.sum +++ b/go.sum @@ -138,6 +138,8 @@ github.com/mewkiz/flac v1.0.5/go.mod h1:EHZNU32dMF6alpurYyKHDLYpW1lYpBZ5WrXi/VuN github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd h1:xKn/gU8lZupoZt/HE7a/R3aH93iUO6JwyRsYelQUsRI= github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd/go.mod h1:B6icauz2l4tkYQxmDtCH4qmNWz/evSW5CsOqp6IE5IE= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -213,6 +215,7 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/scanner/scanner.go b/scanner/scanner.go index b51c0bd..325cdd7 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -57,7 +57,7 @@ type Scanner struct { seenTracksErr int // n tracks we we couldn't scan } -func New(db *db.DB, musicPath string) *Scanner { +func New(musicPath string, db *db.DB) *Scanner { return &Scanner{ db: db, musicPath: musicPath, diff --git a/server/ctrladmin/ctrl.go b/server/ctrladmin/ctrl.go index f811453..6e04864 100644 --- a/server/ctrladmin/ctrl.go +++ b/server/ctrladmin/ctrl.go @@ -83,26 +83,26 @@ type Controller struct { sessDB *gormstore.Store } -func New(base *ctrlbase.Controller) *Controller { - sessionKey := []byte(base.DB.GetSetting("session_key")) +func New(b *ctrlbase.Controller) *Controller { + sessionKey := []byte(b.DB.GetSetting("session_key")) if len(sessionKey) == 0 { sessionKey = securecookie.GenerateRandomKey(32) - base.DB.SetSetting("session_key", string(sessionKey)) + b.DB.SetSetting("session_key", string(sessionKey)) } tmplBase := template. New("layout"). Funcs(sprig.FuncMap()). Funcs(funcMap()). // static Funcs(template.FuncMap{ // from base - "path": base.Path, + "path": b.Path, }) tmplBase = extendFromPaths(tmplBase, prefixPartials) tmplBase = extendFromPaths(tmplBase, prefixLayouts) - sessDB := gormstore.New(base.DB.DB, sessionKey) + sessDB := gormstore.New(b.DB.DB, sessionKey) sessDB.SessionOpts.HttpOnly = true sessDB.SessionOpts.SameSite = http.SameSiteLaxMode return &Controller{ - Controller: base, + Controller: b, buffPool: bpool.NewBufferPool(64), templates: pagesFromPaths(tmplBase, prefixPages), sessDB: sessDB, diff --git a/server/ctrlbase/ctrl.go b/server/ctrlbase/ctrl.go index a886bf8..194ccc0 100644 --- a/server/ctrlbase/ctrl.go +++ b/server/ctrlbase/ctrl.go @@ -7,7 +7,6 @@ import ( "path" "go.senan.xyz/gonic/db" - "go.senan.xyz/gonic/jukebox" "go.senan.xyz/gonic/scanner" ) @@ -50,7 +49,6 @@ type Controller struct { MusicPath string Scanner *scanner.Scanner ProxyPrefix string - Jukebox *jukebox.Jukebox } // Path returns a URL path with the proxy prefix included diff --git a/server/ctrlsubsonic/ctrl.go b/server/ctrlsubsonic/ctrl.go index 8548bbb..b820b4a 100644 --- a/server/ctrlsubsonic/ctrl.go +++ b/server/ctrlsubsonic/ctrl.go @@ -12,6 +12,7 @@ import ( "go.senan.xyz/gonic/server/ctrlbase" "go.senan.xyz/gonic/server/ctrlsubsonic/params" "go.senan.xyz/gonic/server/ctrlsubsonic/spec" + "go.senan.xyz/gonic/server/jukebox" ) type CtxKey int @@ -24,14 +25,8 @@ const ( type Controller struct { *ctrlbase.Controller - cachePath string -} - -func New(base *ctrlbase.Controller, cachePath string) *Controller { - return &Controller{ - Controller: base, - cachePath: cachePath, - } + CachePath string + Jukebox *jukebox.Jukebox } type metaResponse struct { diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index 10d9743..438e6e4 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -317,22 +317,23 @@ func (c *Controller) ServeGetRandomSongs(r *http.Request) *spec.Response { func (c *Controller) ServeJukebox(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) - switch params.Get("action") { - case "set": + getTracks := func() []*db.Track { var tracks []*db.Track ids := params.GetFirstListInt("id") - if len(ids) == 0 { - c.Jukebox.ClearTracks() - } for _, id := range ids { track := &db.Track{} - err := c.DB.Preload("Album").First(track, id).Error - if err != nil { - return spec.NewError(10, "couldn't find tracks with provided ids") + c.DB.Preload("Album").First(track, id) + if track.ID != 0 { + tracks = append(tracks, track) } - tracks = append(tracks, track) } - c.Jukebox.SetTracks(tracks) + return tracks + } + switch act := params.Get("action"); act { + case "set": + c.Jukebox.SetTracks(getTracks()) + case "add": + c.Jukebox.AddTracks(getTracks()) case "clear": c.Jukebox.ClearTracks() case "remove": @@ -356,8 +357,10 @@ func (c *Controller) ServeJukebox(r *http.Request) *spec.Response { sub := spec.NewResponse() sub.JukeboxPlaylist = c.Jukebox.GetTracks() return sub + default: + return spec.NewError(10, "unknown value `%s` for parameter 'action'", act) } - // All actions except get are expected to return a status + // all actions except get are expected to return a status sub := spec.NewResponse() sub.JukeboxStatus = c.Jukebox.Status() return sub diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 58dc290..8ac4982 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -136,7 +136,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R } servOpts.pref = pref servOpts.maxBitrate = params.GetIntOr("maxBitRate", 0) - servOpts.cachePath = c.cachePath + servOpts.cachePath = c.CachePath serveTrackEncode(w, r, servOpts) return nil } diff --git a/jukebox/jukebox.go b/server/jukebox/jukebox.go similarity index 52% rename from jukebox/jukebox.go rename to server/jukebox/jukebox.go index 8a7b126..a6eecf6 100644 --- a/jukebox/jukebox.go +++ b/server/jukebox/jukebox.go @@ -1,6 +1,7 @@ package jukebox import ( + "fmt" "os" "path" "sync" @@ -10,6 +11,7 @@ import ( "github.com/faiface/beep/flac" "github.com/faiface/beep/mp3" "github.com/faiface/beep/speaker" + "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/server/ctrlsubsonic/spec" ) @@ -21,90 +23,142 @@ type strmInfo struct { } type Jukebox struct { - playlist []*db.Track - index int - playing bool + playlist []*db.Track + musicPath string + index int + playing bool + sr beep.SampleRate // used to notify the player to re read the members updates chan struct{} + quit chan struct{} done chan bool info *strmInfo sync.Mutex } -func (j *Jukebox) Init(musicPath string) error { - j.updates = make(chan struct{}) - sr := beep.SampleRate(48000) - err := speaker.Init(sr, sr.N(time.Second/2)) - if err != nil { - return err +func New(musicPath string) *Jukebox { + return &Jukebox{ + musicPath: musicPath, + sr: beep.SampleRate(48000), + updates: make(chan struct{}), + done: make(chan bool), + quit: make(chan struct{}), } - j.done = make(chan bool) - go func() { - for range j.updates { - var streamer beep.Streamer - var format beep.Format - f, err := os.Open(path.Join(musicPath, j.playlist[j.index].RelPath())) - if err != nil { - j.index++ - continue - } - switch j.playlist[j.index].Ext() { - case "mp3": - streamer, format, err = mp3.Decode(f) - case "flac": - streamer, format, err = flac.Decode(f) - default: - j.index++ - continue - } - if err != nil { - j.index++ - continue - } - if j.playing { - j.Lock() - { - j.info = &strmInfo{} - j.info.strm = streamer.(beep.StreamSeekCloser) - j.info.ctrlStrmr.Streamer = beep.Resample(4, format.SampleRate, sr, j.info.strm) - j.info.format = format - } - j.Unlock() - speaker.Play(beep.Seq(&j.info.ctrlStrmr, beep.Callback(func() { - j.done <- false - }))) - if v := <-j.done; !v { - j.index++ - j.Lock() - if j.index > len(j.playlist) { - j.index = 0 - j.playing = false - } - j.Unlock() - // in a go routine as otherwise this hangs as the - go func() { - j.updates <- struct{}{} - }() - } else { - continue - } - } +} + +func (j *Jukebox) Listen() error { + if err := speaker.Init(j.sr, j.sr.N(time.Second/2)); err != nil { + return fmt.Errorf("initing speaker: %w", err) + } + for { + select { + case <-j.quit: + return nil + case <-j.updates: + j.doUpdate() } - }() - return nil + } +} + +func (j *Jukebox) Quit() { + j.quit <- struct{}{} +} + +func (j *Jukebox) doUpdate() { + var streamer beep.Streamer + var format beep.Format + if j.index >= len(j.playlist) { + j.Lock() + j.index = 0 + j.playing = false + j.Unlock() + return + } + j.Lock() + f, err := os.Open(path.Join( + j.musicPath, + j.playlist[j.index].RelPath(), + )) + j.Unlock() + if err != nil { + j.incIndex() + return + } + switch j.playlist[j.index].Ext() { + case "mp3": + streamer, format, err = mp3.Decode(f) + case "flac": + streamer, format, err = flac.Decode(f) + default: + j.incIndex() + return + } + if err != nil { + j.incIndex() + return + } + if j.playing { + j.Lock() + { + j.info = &strmInfo{} + j.info.strm = streamer.(beep.StreamSeekCloser) + j.info.ctrlStrmr.Streamer = beep.Resample( + 4, format.SampleRate, + j.sr, j.info.strm, + ) + j.info.format = format + } + j.Unlock() + speaker.Play(beep.Seq(&j.info.ctrlStrmr, beep.Callback(func() { + j.done <- false + }))) + if v := <-j.done; v { + return + } + j.Lock() + j.index++ + if j.index >= len(j.playlist) { + j.index = 0 + j.playing = false + j.Unlock() + return + } + j.Unlock() + // in a go routine as otherwise this hangs as the + go func() { + j.updates <- struct{}{} + }() + } +} + +func (j *Jukebox) incIndex() { + j.Lock() + defer j.Unlock() + j.index++ } func (j *Jukebox) SetTracks(tracks []*db.Track) { j.Lock() + defer j.Unlock() j.index = 0 - j.playing = true - if len(j.playlist) > 0 { - j.done <- true + if len(tracks) == 0 { + if j.playing { + j.done <- true + } + j.playing = false j.playlist = []*db.Track{} speaker.Clear() + return + } + if j.playing { + j.playlist = tracks + j.done <- true + speaker.Clear() + j.updates <- struct{}{} + return } j.playlist = tracks - j.Unlock() + j.playing = true j.updates <- struct{}{} } @@ -173,20 +227,25 @@ func (j *Jukebox) Stop() { func (j *Jukebox) Start() { j.Lock() - j.playing = false + j.playing = true j.info.ctrlStrmr.Paused = false j.Unlock() } func (j *Jukebox) Skip(i int, skipCurrent bool) { j.Lock() + defer j.Unlock() + if i == j.index { + return + } if skipCurrent { j.index++ } else { j.index = i } speaker.Clear() - j.done <- true + if j.playing { + j.done <- true + } j.updates <- struct{}{} - j.Unlock() } diff --git a/server/server.go b/server/server.go index 8749fe9..e3bf680 100644 --- a/server/server.go +++ b/server/server.go @@ -11,27 +11,25 @@ import ( "github.com/gorilla/mux" "go.senan.xyz/gonic/db" - "go.senan.xyz/gonic/jukebox" "go.senan.xyz/gonic/scanner" "go.senan.xyz/gonic/server/assets" "go.senan.xyz/gonic/server/ctrladmin" "go.senan.xyz/gonic/server/ctrlbase" "go.senan.xyz/gonic/server/ctrlsubsonic" + "go.senan.xyz/gonic/server/jukebox" ) type Options struct { - DB *db.DB - MusicPath string - CachePath string - ListenAddr string - ScanInterval time.Duration - ProxyPrefix string + DB *db.DB + MusicPath string + CachePath string + ProxyPrefix string } type Server struct { - *http.Server - scanner *scanner.Scanner - scanInterval time.Duration + scanner *scanner.Scanner + jukebox *jukebox.Jukebox + router *mux.Router } func New(opts Options) *Server { @@ -39,7 +37,8 @@ func New(opts Options) *Server { opts.MusicPath = filepath.Clean(opts.MusicPath) opts.CachePath = filepath.Clean(opts.CachePath) // ** begin controllers - scanner := scanner.New(opts.DB, opts.MusicPath) + scanner := scanner.New(opts.MusicPath, opts.DB) + jukebox := jukebox.New(opts.MusicPath) // the base controller, it's fields/middlewares are embedded/used by the // other two admin ui and subsonic controllers base := &ctrlbase.Controller{ @@ -47,30 +46,25 @@ func New(opts Options) *Server { MusicPath: opts.MusicPath, ProxyPrefix: opts.ProxyPrefix, Scanner: scanner, - Jukebox: &jukebox.Jukebox{}, } - base.Jukebox.Init(opts.MusicPath) // router with common wares for admin / subsonic r := mux.NewRouter() r.Use(base.WithLogging) r.Use(base.WithCORS) - setupMisc(r, base) - setupAdminRouter := r.PathPrefix("/admin").Subrouter() - setupAdmin(setupAdminRouter, ctrladmin.New(base)) - setupSubsonicRouter := r.PathPrefix("/rest").Subrouter() - setupSubsonic(setupSubsonicRouter, ctrlsubsonic.New(base, opts.CachePath)) - // - server := &http.Server{ - Addr: opts.ListenAddr, - Handler: r, - ReadTimeout: 5 * time.Second, - WriteTimeout: 80 * time.Second, - IdleTimeout: 60 * time.Second, + ctrlAdmin := ctrladmin.New(base) + ctrlSubsonic := &ctrlsubsonic.Controller{ + Controller: base, + CachePath: opts.CachePath, + Jukebox: jukebox, } + setupMisc(r, base) + setupAdmin(r.PathPrefix("/admin").Subrouter(), ctrlAdmin) + setupSubsonic(r.PathPrefix("/rest").Subrouter(), ctrlSubsonic) + // return &Server{ - Server: server, - scanner: scanner, - scanInterval: opts.ScanInterval, + scanner: scanner, + jukebox: jukebox, + router: r, } } @@ -183,17 +177,54 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) { r.NotFoundHandler = notFoundRoute.GetHandler() } -func (s *Server) Start() error { - if s.scanInterval > 0 { - log.Printf("will be scanning at intervals of %s", s.scanInterval) - ticker := time.NewTicker(s.scanInterval) - go func() { - for range ticker.C { +type funcExecute func() error +type funcInterrupt func(error) + +func (s *Server) StartHTTP(listenAddr string) (funcExecute, funcInterrupt) { + log.Print("starting job 'http'\n") + list := &http.Server{ + Addr: listenAddr, + Handler: s.router, + ReadTimeout: 5 * time.Second, + WriteTimeout: 80 * time.Second, + IdleTimeout: 60 * time.Second, + } + execute := func() error { + return list.ListenAndServe() + } + return execute, func(_ error) { + list.Close() + } +} + +func (s *Server) StartScanTicker(dur time.Duration) (funcExecute, funcInterrupt) { + log.Printf("starting job 'scan timer'\n") + ticker := time.NewTicker(dur) + done := make(chan struct{}) + execute := func() error { + for { + select { + case <-done: + return nil + case <-ticker.C: if err := s.scanner.Start(); err != nil { - log.Printf("error while scanner: %v", err) + log.Printf("error scanning: %v", err) } } - }() + } + } + return execute, func(_ error) { + ticker.Stop() + done <- struct{}{} + } +} + +func (s *Server) StartJukebox() (funcExecute, funcInterrupt) { + log.Printf("starting job 'jukebox'\n") + execute := func() error { + return s.jukebox.Listen() + } + return execute, func(_ error) { + s.jukebox.Quit() } - return s.ListenAndServe() }