diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index b405448..eee83bf 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -1,7 +1,8 @@ -//nolint:lll,gocyclo,forbidigo +//nolint:lll,gocyclo,forbidigo,nilerr package main import ( + "context" "errors" "expvar" "flag" @@ -9,10 +10,12 @@ import ( "log" "net/http" "os" + "os/signal" "path" "path/filepath" "regexp" "strings" + "syscall" "time" // avatar encode/decode @@ -22,9 +25,9 @@ import ( "github.com/google/shlex" "github.com/gorilla/securecookie" _ "github.com/jinzhu/gorm/dialects/sqlite" - "github.com/oklog/run" "github.com/peterbourgon/ff" "github.com/sentriz/gormstore" + "golang.org/x/sync/errgroup" "go.senan.xyz/gonic" "go.senan.xyz/gonic/db" @@ -51,7 +54,7 @@ func main() { confTLSCert := set.String("tls-cert", "", "path to TLS certificate (optional)") confTLSKey := set.String("tls-key", "", "path to TLS private key (optional)") - confPodcastPurgeAgeDays := set.Int("podcast-purge-age", 0, "age (in days) to purge podcast episodes if not accessed (optional)") + confPodcastPurgeAgeDays := set.Uint("podcast-purge-age", 0, "age (in days) to purge podcast episodes if not accessed (optional)") confPodcastPath := set.String("podcast-path", "", "path to podcasts") confCachePath := set.String("cache-path", "", "path to cache") @@ -63,7 +66,7 @@ func main() { confDBPath := set.String("db-path", "gonic.db", "path to database (optional)") - confScanIntervalMins := set.Int("scan-interval", 0, "interval (in minutes) to automatically scan music (optional)") + confScanIntervalMins := set.Uint("scan-interval", 0, "interval (in minutes) to automatically scan music (optional)") confScanAtStart := set.Bool("scan-at-start-enabled", false, "whether to perform an initial scan at startup (optional)") confScanWatcher := set.Bool("scan-watcher-enabled", false, "whether to watch file system for new music and rescan (optional)") @@ -263,7 +266,6 @@ func main() { expvar.Publish("stats", expvar.Func(func() any { var stats struct{ Albums, Tracks, Artists, InternetRadioStations, Podcasts uint } dbc.Model(db.Album{}).Count(&stats.Albums) - dbc.Model(db.Track{}).Count(&stats.Tracks) dbc.Model(db.Artist{}).Count(&stats.Artists) dbc.Model(db.InternetRadioStation{}).Count(&stats.InternetRadioStations) dbc.Model(db.Podcast{}).Count(&stats.Podcasts) @@ -271,122 +273,184 @@ func main() { })) } - noCleanup := func(_ error) {} + errgrp, ctx := errgroup.WithContext(context.Background()) - var g run.Group - g.Add(func() error { - log.Print("starting job 'http'\n") - server := &http.Server{ - Addr: *confListenAddr, - Handler: mux, - ReadTimeout: 5 * time.Second, - ReadHeaderTimeout: 5 * time.Second, - WriteTimeout: 80 * time.Second, - IdleTimeout: 60 * time.Second, - } + errgrp.Go(func() error { + defer logJob("http")() + + server := &http.Server{Addr: *confListenAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + errgrp.Go(func() error { + <-ctx.Done() + return server.Shutdown(context.Background()) + }) if *confTLSCert != "" && *confTLSKey != "" { return server.ListenAndServeTLS(*confTLSCert, *confTLSKey) } return server.ListenAndServe() - }, noCleanup) + }) + + errgrp.Go(func() error { + defer logJob("session clean")() - g.Add(func() error { - log.Printf("starting job 'session clean'\n") ticker := time.NewTicker(10 * time.Minute) - for range ticker.C { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: sessDB.Cleanup() } return nil - }, noCleanup) + }) + + errgrp.Go(func() error { + defer logJob("podcast refresh")() - g.Add(func() error { - log.Printf("starting job 'podcast refresher'\n") ticker := time.NewTicker(time.Hour) - for range ticker.C { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: if err := podcast.RefreshPodcasts(); err != nil { log.Printf("failed to refresh some feeds: %s", err) } } return nil - }, noCleanup) + }) - if *confPodcastPurgeAgeDays > 0 { - g.Add(func() error { - log.Printf("starting job 'podcast purger'\n") - ticker := time.NewTicker(24 * time.Hour) - for range ticker.C { - if err := podcast.PurgeOldPodcasts(time.Duration(*confPodcastPurgeAgeDays) * 24 * time.Hour); err != nil { - log.Printf("error purging old podcasts: %v", err) - } - } + errgrp.Go(func() error { + if *confPodcastPurgeAgeDays == 0 { return nil - }, noCleanup) - } - - if *confScanIntervalMins > 0 { - g.Add(func() error { - log.Printf("starting job 'scan timer'\n") - ticker := time.NewTicker(time.Duration(*confScanIntervalMins) * time.Minute) - for range ticker.C { - if _, err := scannr.ScanAndClean(scanner.ScanOptions{}); err != nil { - log.Printf("error scanning: %v", err) - } - } - return nil - }, noCleanup) - } - - if *confScanWatcher { - g.Add(func() error { - log.Printf("starting job 'scan watcher'\n") - return scannr.ExecuteWatch() - }, func(_ error) { - scannr.CancelWatch() - }) - } - - if jukebx != nil { - var jukeboxTempDir string - g.Add(func() error { - log.Printf("starting job 'jukebox'\n") - extraArgs, _ := shlex.Split(*confJukeboxMPVExtraArgs) - var err error - jukeboxTempDir, err = os.MkdirTemp("", "gonic-jukebox-*") - if err != nil { - return fmt.Errorf("create tmp sock file: %w", err) - } - sockPath := filepath.Join(jukeboxTempDir, "sock") - if err := jukebx.Start(sockPath, extraArgs); err != nil { - return fmt.Errorf("start jukebox: %w", err) - } - if err := jukebx.Wait(); err != nil { - return fmt.Errorf("start jukebox: %w", err) - } - return nil - }, func(_ error) { - if err := jukebx.Quit(); err != nil { - log.Printf("error quitting jukebox: %v", err) - } - _ = os.RemoveAll(jukeboxTempDir) - }) - } - - if _, _, err := lastfmClientKeySecretFunc(); err == nil { - g.Add(func() error { - log.Printf("starting job 'refresh artist info'\n") - return artistInfoCache.Refresh(8 * time.Second) - }, noCleanup) - } - - if *confScanAtStart { - if _, err := scannr.ScanAndClean(scanner.ScanOptions{}); err != nil { - log.Panicf("error scanning at start: %v\n", err) } + + defer logJob("podcast purge")() + + ticker := time.NewTicker(24 * time.Hour) + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := podcast.PurgeOldPodcasts(time.Duration(*confPodcastPurgeAgeDays) * 24 * time.Hour); err != nil { + log.Printf("error purging old podcasts: %v", err) + } + } + return nil + }) + + errgrp.Go(func() error { + if *confScanIntervalMins == 0 { + return nil + } + + defer logJob("scan timer")() + + ticker := time.NewTicker(time.Duration(*confScanIntervalMins) * time.Minute) + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if _, err := scannr.ScanAndClean(scanner.ScanOptions{}); err != nil { + log.Printf("error scanning: %v", err) + } + } + return nil + }) + + errgrp.Go(func() error { + if !*confScanWatcher { + return nil + } + + defer logJob("scan watcher")() + + errgrp.Go(func() error { + <-ctx.Done() + scannr.CancelWatch() + return nil + }) + return scannr.ExecuteWatch() + }) + + errgrp.Go(func() error { + if jukebx == nil { + return nil + } + + defer logJob("jukebox")() + + extraArgs, _ := shlex.Split(*confJukeboxMPVExtraArgs) + var err error + cacheDir, err := os.UserCacheDir() + if err != nil { + return fmt.Errorf("get user cache dir: %w", err) + } + jukeboxTempDir := filepath.Join(cacheDir, "gonic-jukebox") + if err := os.RemoveAll(jukeboxTempDir); err != nil { + return fmt.Errorf("remove jubebox tmp dir: %w", err) + } + if err := os.MkdirAll(jukeboxTempDir, os.ModePerm); err != nil { + return fmt.Errorf("create tmp sock file: %w", err) + } + sockPath := filepath.Join(jukeboxTempDir, "sock") + if err := jukebx.Start(sockPath, extraArgs); err != nil { + return fmt.Errorf("start jukebox: %w", err) + } + errgrp.Go(func() error { + <-ctx.Done() + return jukebx.Quit() + }) + if err := jukebx.Wait(); err != nil { + return fmt.Errorf("start jukebox: %w", err) + } + return nil + }) + + errgrp.Go(func() error { + if _, _, err := lastfmClientKeySecretFunc(); err != nil { + return nil + } + + defer logJob("refresh artist info")() + + ticker := time.NewTicker(8 * time.Second) + select { + case <-ctx.Done(): + case <-ticker.C: + if err := artistInfoCache.Refresh(); err != nil { + log.Printf("error in artist info cache: %v", err) + } + } + return nil + }) + + errgrp.Go(func() error { + if !*confScanAtStart { + return nil + } + + defer logJob("scan at start")() + + _, err := scannr.ScanAndClean(scanner.ScanOptions{}) + return err + }) + + errShutdown := errors.New("shutdown") + + errgrp.Go(func() error { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + select { + case <-ctx.Done(): + return nil + case <-sigChan: + return errShutdown + } + }) + + if err := errgrp.Wait(); err != nil && !errors.Is(err, errShutdown) { + log.Panic(err) } - if err := g.Run(); err != nil { - log.Panicf("error in job: %v", err) - } + fmt.Println("shutdown complete") } const pathAliasSep = "->" @@ -461,3 +525,8 @@ func (mvs *multiValueSetting) Set(value string) error { } return nil } + +func logJob(jobName string) func() { + log.Printf("starting job %q", jobName) + return func() { log.Printf("stopped job %q", jobName) } +} diff --git a/go.mod b/go.mod index 7395f82..14ab40a 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,6 @@ require ( github.com/mitchellh/mapstructure v1.5.0 github.com/mmcdole/gofeed v1.2.1 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 - github.com/oklog/run v1.1.0 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c github.com/peterbourgon/ff v1.7.1 github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4 @@ -30,6 +29,7 @@ require ( github.com/stretchr/testify v1.8.1 golang.org/x/exp v0.0.0-20230905200255-921286631fa9 golang.org/x/net v0.15.0 + golang.org/x/sync v0.3.0 gopkg.in/gormigrate.v1 v1.6.0 jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 ) diff --git a/go.sum b/go.sum index d9026a2..316f5b2 100644 --- a/go.sum +++ b/go.sum @@ -119,8 +119,6 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -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/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= @@ -184,6 +182,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/jukebox/jukebox.go b/jukebox/jukebox.go index c29cfdc..9d659a3 100644 --- a/jukebox/jukebox.go +++ b/jukebox/jukebox.go @@ -310,9 +310,9 @@ func (j *Jukebox) Quit() error { if j.conn == nil || j.conn.IsClosed() { return nil } - if _, err := j.conn.Call("quit"); err != nil { - return fmt.Errorf("quit: %w", err) - } + go func() { + j.conn.Call("quit") + }() if err := j.conn.Close(); err != nil { return fmt.Errorf("close: %w", err) } diff --git a/server/ctrlsubsonic/artistinfocache/artistinfocache.go b/server/ctrlsubsonic/artistinfocache/artistinfocache.go index 8a7f600..63bde0c 100644 --- a/server/ctrlsubsonic/artistinfocache/artistinfocache.go +++ b/server/ctrlsubsonic/artistinfocache/artistinfocache.go @@ -94,29 +94,24 @@ func (a *ArtistInfoCache) Lookup(ctx context.Context, artist *db.Artist) (*db.Ar return &artistInfo, nil } -func (a *ArtistInfoCache) Refresh(interval time.Duration) error { - ticker := time.NewTicker(interval) - for range ticker.C { - q := a.db. - Where("artist_infos.id IS NULL OR artist_infos.updated_at