diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 8a6b676..a7492d0 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -27,4 +27,21 @@ jobs: run: go build -v ./... - name: Test - run: go test -v ./... \ No newline at end of file + run: go test -v ./... + # configuration: https://github.com/golangci/golangci-lint-action + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: '1.20' + cache: false + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + # Require: The version of golangci-lint to use. + # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. + # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. + version: v1.53 \ No newline at end of file diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..dc79829 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,144 @@ +linters-settings: + gosec: + # To select a subset of rules to run. + # Available rules: https://github.com/securego/gosec#available-rules + # includes: + # To specify a set of rules to explicitly exclude. + # Available rules: https://github.com/securego/gosec#available-rules + excludes: + - G204 + # To specify the configuration of rules. + # The configuration of rules is not fully documented by gosec: + # https://github.com/securego/gosec#configuration + # https://github.com/securego/gosec/blob/569328eade2ccbad4ce2d0f21ee158ab5356a5cf/rules/rulelist.go#L60-L102 +linters: + # Enable specific linter + # https://golangci-lint.run/usage/linters/#enabled-by-default + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - containedctx + - contextcheck + - cyclop + - deadcode + - decorder + - dogsled + - dupl + - dupword + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - execinquery + - exhaustive + - exhaustruct + - exportloopref + - forbidigo + - forcetypeassert + - funlen + - gci + - ginkgolinter + - gocheckcompilerdirectives + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - godox + - gofmt + - gofumpt + - goheader + - goimports + - revive + - gomnd + - gomoddirectives + - gomodguard + - goprintffuncname + - gosec + - gosimple + - gosmopolitan + - govet + - grouper + - importas + - ineffassign + - interfacebloat + - lll + - loggercheck + - maintidx + - makezero + - mirror + - misspell + - musttag + - nakedret + - nestif + - nilerr + - nilnil + - nlreturn + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - prealloc + - predeclared + - promlinter + - reassign + - revive + - rowserrcheck + - exportloopref + - sqlclosecheck + - staticcheck + - stylecheck + - tagalign + - tagliatelle + - tenv + - testableexamples + - testpackage + - thelper + - tparallel + - typecheck + - unconvert + - unparam + - unused + - usestdlibvars + - varcheck + - wastedassign + - whitespace + - wsl + - zerologlint + + disable: + - wrapcheck + - depguard + - varnamelen + - nonamedreturns + - ireturn + - ifshort + - gochecknoinits + - gochecknoglobals + + # Enable presets. + # https://golangci-lint.run/usage/linters + presets: + - bugs + - comment + - complexity + - error + - format + - import + - metalinter + - module + - performance + - sql + - style + - test + - unused + # Run only fast linters from enabled linters set (first run won't be fast) + # Default: false + fast: true + +run: + skip-files: + - '.*_test\.go' \ No newline at end of file diff --git a/README.md b/README.md index 5d32aa7..e968764 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,9 @@ streams don't work. I've built this out for my specific use case and released it to the public when I considered it feature complete. Suggestions are welcome for adding additional features. ## Development Setup +Note: steps 3 & 4 assume a developer is on some distribution of Debian/Ubuntu. If not on that distribution, +you can simply look at what is happening in `taskfile.dev` and replicate that in your environment. + 1. Install [golang](https://go.dev/doc/install) 2. Install [taskfile.dev](https://taskfile.dev/installation/) on your machine. @@ -158,3 +161,5 @@ I've built this out for my specific use case and released it to the public when If all passes without errors, you should be set to use this binary. Checkout the `taskfile.dev` for additional features commands you can run. +### Linting +Linting is performed by [golangci-lint](https://golangci-lint.run/) \ No newline at end of file diff --git a/cache/cache.go b/cache/cache.go index 5a136ff..25ce00e 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -7,7 +7,6 @@ import ( ) const ( - defaultPodcastCache = "podcastCache" podcastCacheLocalFile = "./cache/podcastCache.json" ) @@ -15,9 +14,11 @@ var PodcastPlayedCache *zcache.Cache func ClearPodcastPlayedCache() error { PodcastPlayedCache.Flush() + err := os.Remove(podcastCacheLocalFile) if err != nil { return err } + return nil } diff --git a/content/file.go b/content/file.go index 69d8df6..988fbc3 100644 --- a/content/file.go +++ b/content/file.go @@ -20,10 +20,11 @@ import ( ) const ( - wavFile = "wav" - mp3File = "mp3" - oggFile = "oggs" - flacFile = "flac" + wavFile = "wav" + mp3File = "mp3" + oggFile = "oggs" + flacFile = "flac" + sampleRateTime = 10 ) type LocalFile struct { @@ -37,64 +38,78 @@ type LocalFile struct { func (l *LocalFile) Get() error { log.Infof("buffering file from %s", l.Path) + f, err := os.Open(l.Path) if err != nil { - return errors.New(fmt.Sprintf("unable to open file from path: %v", err)) + return errors.New(fmt.Sprintf("unable to open file from path: %v", err)) //nolint:lll,revive,gosimple,nolintlint // error pref } + log.Infof("decoding file from %v", l.Path) l.Content = f + return nil } func (l *LocalFile) Play() error { var streamer beep.StreamSeekCloser - var format beep.Format + var format beep.Format //nolint:wsl // it's fine for declarations to touch + err := l.setDecoder() if err != nil { - return errors.New(fmt.Sprintf("error setting decoder: %v", err)) + return errors.New(fmt.Sprintf("error setting decoder: %v", err)) //nolint:revive,gosimple // error pref } + _, err = l.Content.Seek(0, 0) if err != nil { - return errors.New(fmt.Sprintf("unable to seek to beginning of file: %v", err)) + return errors.New(fmt.Sprintf("unable to seek to beginning of file: %v", err)) //nolint:lll,revive,gosimple,nolintlint // error pref } + if l.fileType == wavFile || l.fileType == flacFile { streamer, format, err = l.decodeReader(l.Content) if err != nil { - return errors.New(fmt.Sprintf("unable to decode file: %v", err)) + return errors.New(fmt.Sprintf("unable to decode file: %v", err)) //nolint:revive,gosimple // error pref } } + if l.fileType == mp3File || l.fileType == oggFile { streamer, format, err = l.decodeReadCloser(l.Content) if err != nil { log.WithError(err).Fatal("unable to decode file") } } + log.Infof("playing file buffer from %v", l.Path) - err = speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)) + + err = speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/sampleRateTime)) if err != nil { - return errors.New(fmt.Sprintf("unable to play file: %v", err)) + return errors.New(fmt.Sprintf("unable to play file: %v", err)) //nolint:revive,gosimple,nolintlint // error pref } + done := make(chan bool) - speaker.Play(beep.Seq(streamer, beep.Callback(func() { + speaker.Play(beep.Seq(streamer, beep.Callback(func() { //nolint:wsl // grouping makes sense here done <- true }))) <-done + return nil } func (l *LocalFile) Stop() error { log.Infof("file.Stop::Stopping stream from %v ", l.Path) + return nil } func (l *LocalFile) setDecoder() error { buf, err := io.ReadAll(l.Content) if buf == nil { - return errors.New("empty bytes") + return errors.New("empty bytes") //nolint:goerr113 // we want this error as it is. } + if err != nil { return err } + switch l.getFileType(buf) { case wavFile: l.fileType = wavFile @@ -109,31 +124,43 @@ func (l *LocalFile) setDecoder() error { l.fileType = oggFile l.decodeReadCloser = vorbis.Decode default: - l.Stop() + err := l.Stop() + if err != nil { + log.WithError(err).Error("localFile.setDecoder::error stopping local file") + } + unknownType, err := filetype.Match(buf) if err != nil { - log.WithError(err).Error("error getting filetype") + log.WithError(err).Error("localFile.setDecoder::error getting filetype") } - return errors.New("unsupported filetype " + unknownType.Extension) + + return errors.New("unsupported filetype " + unknownType.Extension) //nolint:goerr113 // desired error } + return nil } + func (l *LocalFile) getFileType(buf []byte) string { ext := filepath.Ext(l.Path) trimmedExt := strings.TrimLeft(ext, ".") // remove the delimiter + // added the trim check because some supported filetypes were not recognized by // the filetype.IsType function despite having proper extension and working with the respective decoder if filetype.IsType(buf, filetype.GetType("wav")) || trimmedExt == wavFile { return wavFile } + if filetype.IsType(buf, filetype.GetType("mp3")) || trimmedExt == mp3File { return mp3File } + if filetype.IsType(buf, filetype.GetType("ogg")) || trimmedExt == oggFile { return oggFile } + if filetype.IsType(buf, filetype.GetType("flac")) || trimmedExt == flacFile { return flacFile } + return "" } diff --git a/content/file_test.go b/content/file_test.go index c0762ab..17bd0c9 100644 --- a/content/file_test.go +++ b/content/file_test.go @@ -1,11 +1,13 @@ +// nolint:TODO https://github.com/jmillerv/go-dj/issues/16 package content_test import ( - "github.com/faiface/beep" - "github.com/jmillerv/go-dj/content" "io" "os" "testing" + + "github.com/faiface/beep" + "github.com/jmillerv/go-dj/content" ) func TestLocalFile_Get(t *testing.T) { @@ -22,7 +24,7 @@ func TestLocalFile_Get(t *testing.T) { fields fields wantErr bool }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -52,7 +54,7 @@ func TestLocalFile_Play(t *testing.T) { fields fields wantErr bool }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -82,7 +84,7 @@ func TestLocalFile_Stop(t *testing.T) { fields fields wantErr bool }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/content/folder.go b/content/folder.go index 68c3d9b..c8ba5a1 100644 --- a/content/folder.go +++ b/content/folder.go @@ -11,7 +11,7 @@ import ( // The folder structure exists because I didn't want to load an entire folder's worth // of songs into memory like the LocalFile struct does. -// Folder is a struct for parsing folders that implements the Media interface +// Folder is a struct for parsing folders that implements the Media interface. type Folder struct { Name string Content []os.DirEntry @@ -20,10 +20,12 @@ type Folder struct { func (f *Folder) Get() (err error) { log.Infof("buffering files from %s", f.Path) + f.Content, err = os.ReadDir(f.Path) if err != nil { - return errors.New(fmt.Sprintf("unable to read folder from path: %v", err)) + return errors.New(fmt.Sprintf("unable to read folder from path: %v", err)) //nolint:lll,revive,gosimple,nolintlint // error pref } + return nil } @@ -34,27 +36,32 @@ func (f *Folder) Play() error { if err != nil { return err } + err = localFile.Play() if err != nil { return err } } + return nil } func (f *Folder) Stop() error { log.Infof("folder.Stop::Stopping stream from %v ", f.Path) + return nil } func (f *Folder) getLocalFile(file os.DirEntry) (*LocalFile, error) { - localFile := &LocalFile{ + localFile := &LocalFile{ //nolint:exhaustruct // we don't need to assign everything here Name: file.Name(), Path: f.Path + "/" + file.Name(), } + err := localFile.Get() if err != nil { return nil, err } + return localFile, nil } diff --git a/content/folder_test.go b/content/folder_test.go index 9fb0417..ddb4607 100644 --- a/content/folder_test.go +++ b/content/folder_test.go @@ -1,9 +1,11 @@ +// nolint:TODO https://github.com/jmillerv/go-dj/issues/16 package content_test import ( - "github.com/jmillerv/go-dj/content" "os" "testing" + + "github.com/jmillerv/go-dj/content" ) func TestFolder_Get(t *testing.T) { @@ -17,7 +19,7 @@ func TestFolder_Get(t *testing.T) { fields fields wantErr bool }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -44,7 +46,7 @@ func TestFolder_Play(t *testing.T) { fields fields wantErr bool }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -71,7 +73,7 @@ func TestFolder_Stop(t *testing.T) { fields fields wantErr bool }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/content/media.go b/content/media.go index 79629dd..a8cd54b 100644 --- a/content/media.go +++ b/content/media.go @@ -1,8 +1,9 @@ package content import ( - "github.com/faiface/beep" "io" + + "github.com/faiface/beep" ) // content type should be able to be set from the configuration diff --git a/content/podcast.go b/content/podcast.go index 9a7756f..ad5b175 100644 --- a/content/podcast.go +++ b/content/podcast.go @@ -22,60 +22,64 @@ const ( podcastCacheLocalFile = "./cache/podcastCache.json" localFileTTY = "72h" defaultPodcastPlayDuration = "1h" + cachePermissions = 0644 //nolint:gofumpt // gofumpt does weird things ) -var pods podcasts // holds the feed data for podcasts -var podcastStream streamPlayer -var podcastCache podcastCacheData +var ( + pods podcasts // holds the feed data for podcasts + podcastStream streamPlayer + podcastCache podcastCacheData +) type Podcast struct { Name string URL string Player streamPlayer PlayOrder PlayOrder - EpisodeGuid string + EpisodeGUID string TTL time.Duration // cache expiration time } type PlayOrder string // Get parses a podcast feed and sets the most recent episode as the Podcast content. -func (p *Podcast) Get() error { +func (p *Podcast) Get() error { //nolint:cyclop,funlen // complexity of 11, ignore for now. var ep episode + parser := gofeed.NewParser() + feed, err := parser.ParseURL(p.URL) if err != nil { return err } + // traverse links - for _, item := range feed.Items { - pods.Episodes = append(pods.Episodes, item) - } + pods.Episodes = append(pods.Episodes, feed.Items...) + // returns from function should break the switch switch p.PlayOrder { case playOrderNewest: ep = pods.getNewestEpisode() - break case playOrderOldest: ep = pods.getOldestEpisode() - break case playOrderRandom: ep = pods.getRandomEpisode() - break } // set guid for cache when played if ep.Item != nil { - p.EpisodeGuid = ep.Item.GUID + p.EpisodeGUID = ep.Item.GUID } // setup podcast stream podcastStream.playerName = streamPlayerName podcastStream.url = ep.EpURL podcastStream.command = exec.Command(podcastStream.playerName, "-quiet", podcastStream.url) + podcastStream.in, err = podcastStream.command.StdinPipe() if err != nil { return errors.Wrap(err, "error creating standard pipe in") } + podcastStream.out, err = podcastStream.command.StdoutPipe() if err != nil { return errors.Wrap(err, "error creating standard pipe out") @@ -91,6 +95,7 @@ func (p *Podcast) Get() error { } } else { log.Infof("podcast lacks duration, setting default duration") + podcastStream.setDuration(defaultPodcastPlayDuration) if err != nil { return errors.Wrap(err, "error parsing duration") @@ -108,74 +113,93 @@ func (p *Podcast) Get() error { // Play sends the audio to the output. It caches a played episode in the cache ofr later checks. func (p *Podcast) Play() error { log.Infof("streaming from %v ", p.URL) + if !p.Player.isPlaying { - log.WithField("episode", p.EpisodeGuid).Info("setting podcast played cache") + log.WithField("episode", p.EpisodeGUID).Info("setting podcast played cache") + cacheData, cacheHit := cache.PodcastPlayedCache.Get(defaultPodcastCache) if cacheHit { - podcastCache = cacheData.(podcastCacheData) + podcastCache = cacheData.(podcastCacheData) //nolint:forcetypeassert // TODO: type checking } - if p.EpisodeGuid != "" { - podcastCache.Guids = append(podcastCache.Guids, p.EpisodeGuid) + + if p.EpisodeGUID != "" { + podcastCache.Guids = append(podcastCache.Guids, p.EpisodeGUID) } + err := p.setCache(&podcastCache) if err != nil { return err } + err = p.Player.command.Start() + if err != nil { return errors.Wrap(err, "podcast.Play::error starting podcast streamPlayer") } + p.Player.isPlaying = true done := make(chan bool) + func() { p.Player.pipeChan <- p.Player.out done <- true }() <-done } + return nil } func (p *Podcast) Stop() error { log.Infof("poadcast.Stop::Stopping stream from %v ", p.URL) + if p.Player.isPlaying { p.Player.isPlaying = false + _, err := p.Player.in.Write([]byte("q")) if err != nil { log.WithError(err).Errorf("podcast.Stop::error stopping %s: w.Player.in.Write()", p.Player.playerName) } + err = p.Player.in.Close() if err != nil { log.WithError(err).Errorf("podcast.Stop::error stopping %s: w.Player.in.Write()", p.Player.playerName) } + err = p.Player.out.Close() if err != nil { log.WithError(err).Errorf("podcast.Stop::error stopping %s: w.Player.in.Write()", p.Player.playerName) } - p.Player.command = nil + p.Player.command = nil p.Player.url = "" } + return nil } -// setCache updates the in memory cache and persists a local file +// setCache updates the in memory cache and persists a local file. func (p *Podcast) setCache(cacheData *podcastCacheData) error { cache.PodcastPlayedCache.Set(defaultPodcastCache, cacheData, zcache.DefaultExpiration) cacheData.TTY = localFileTTY - cacheData.CacheDate = time.Now() // This will keep the cache constantly refreshing every time an episode is played. // TODO improve solution + + //nolint:godox,nolintlint // TODO: improve solution + cacheData.CacheDate = time.Now() // This will keep the cache constantly refreshing every time an episode is played. + file, err := json.MarshalIndent(cacheData, "", " ") if err != nil { return err } - err = os.WriteFile(podcastCacheLocalFile, file, 0644) + + err = os.WriteFile(podcastCacheLocalFile, file, cachePermissions) if err != nil { return err } + return nil } -// HydratePodcastCache populates the default podcast cache with a local file +// HydratePodcastCache populates the default podcast cache with a local file. func HydratePodcastCache() { // check if file exists file, err := os.ReadFile(podcastCacheLocalFile) @@ -183,21 +207,28 @@ func HydratePodcastCache() { // if file does not exist do not hydrate the cache return } - data := podcastCacheData{} + + data := podcastCacheData{} //nolint:exhaustruct // we don't need to assign everything here + err = json.Unmarshal(file, &data) if err != nil { log.WithError(err).Error("HydratePodcastCache::failed to unmarshal podcast cache local file") + return } + // check that TTY is within range of cacheDate duration, err := time.ParseDuration(data.TTY) if err != nil { log.WithError(err).Error("HydratePodcastCache::failed to parse tty") + return } + if !data.CacheDate.Before(data.CacheDate.Add(duration)) { // if TTY is not within range, do not hydrate return } + cache.PodcastPlayedCache.Set(defaultPodcastCache, data, zcache.DefaultExpiration) } diff --git a/content/podcast_episodes_internal.go b/content/podcast_episodes_internal.go index 3fc3195..c4fbee7 100644 --- a/content/podcast_episodes_internal.go +++ b/content/podcast_episodes_internal.go @@ -14,9 +14,9 @@ type podcasts struct { } type podcastCacheData struct { - Guids []string - TTY string - CacheDate time.Time + Guids []string `json:"guids"` + TTY string `json:"tty"` + CacheDate time.Time `json:"cacheDate"` } func (p *podcastCacheData) fromCache(cacheData any) *podcastCacheData { @@ -24,24 +24,29 @@ func (p *podcastCacheData) fromCache(cacheData any) *podcastCacheData { if ok { return &data } + return nil } +//nolint:ineffassign,staticcheck,wastedassign func (p *podcasts) getNewestEpisode() episode { var newestEpisode episode - var date *time.Time + var date *time.Time //nolint:wsl // declarations are fine to cuddle + date = p.Episodes[0].PublishedParsed - for i, ep := range p.Episodes { + for i, ep := range p.Episodes { // check for cacheData cache cacheData, cacheHit := cache.PodcastPlayedCache.Get(defaultPodcastCache) if cacheHit { - retrieved := (&podcastCacheData{}).fromCache(cacheData) + retrieved := (&podcastCacheData{}).fromCache(cacheData) //nolint:exhaustruct // need type casting if contains(retrieved.Guids, ep.GUID) { continue } } + date = p.Episodes[i].PublishedParsed // update date + if ep.PublishedParsed.After(*date) || ep.PublishedParsed.Equal(*date) { date = ep.PublishedParsed newestEpisode.Item = ep @@ -49,21 +54,26 @@ func (p *podcasts) getNewestEpisode() episode { newestEpisode.EpExtension = ep.Enclosures[0].Type } } + return newestEpisode } +//nolint:ineffassign,staticcheck,wastedassign func (p *podcasts) getOldestEpisode() episode { var oldestEpisode episode - var date *time.Time + var date *time.Time //nolint:wsl // it's fine to cuddle declarations + date = p.Episodes[0].PublishedParsed // update date + for i, ep := range p.Episodes { cacheData, cacheHit := cache.PodcastPlayedCache.Get(ep.GUID) if cacheHit { - retrieved := (&podcastCacheData{}).fromCache(cacheData) + retrieved := (&podcastCacheData{}).fromCache(cacheData) //nolint:exhaustruct // typecasting if contains(retrieved.Guids, ep.GUID) { continue } } + date = p.Episodes[i].PublishedParsed // update date if ep.PublishedParsed.Before(*date) || ep.PublishedParsed.Equal(*date) { date = ep.PublishedParsed @@ -72,25 +82,32 @@ func (p *podcasts) getOldestEpisode() episode { oldestEpisode.EpExtension = ep.Enclosures[0].Type } } + return oldestEpisode } func (p *podcasts) getRandomEpisode() episode { var randomEpisode episode + rand.Seed(time.Now().UnixNano()) - item := p.Episodes[rand.Intn(len(p.Episodes))] + + item := p.Episodes[rand.Intn(len(p.Episodes))] //nolint:gosec // weak generator fine fo this purpose _, cacheHit := cache.PodcastPlayedCache.Get(item.GUID) + // block until cacheHit != true - for cacheHit == true { - item = p.Episodes[rand.Intn(len(p.Episodes))] + for cacheHit { + item = p.Episodes[rand.Intn(len(p.Episodes))] //nolint:gosec // weak generator fine fo this purpose + _, cacheHit = cache.PodcastPlayedCache.Get(item.GUID) if cacheHit { continue } } + randomEpisode.Item = item randomEpisode.EpExtension = item.Enclosures[0].Type randomEpisode.EpURL = item.Enclosures[0].URL + return randomEpisode } @@ -100,9 +117,9 @@ type episode struct { EpURL string } -func contains(guids []string, episodeGuid string) bool { +func contains(guids []string, episodeGUID string) bool { for _, v := range guids { - if v == episodeGuid { + if v == episodeGUID { return true } } diff --git a/content/podcast_episodes_internal_test.go b/content/podcast_episodes_internal_test.go index 79e09c7..a2034ec 100644 --- a/content/podcast_episodes_internal_test.go +++ b/content/podcast_episodes_internal_test.go @@ -1,12 +1,14 @@ +// nolint:TODO https://github.com/jmillerv/go-dj/issues/16 package content // file labeled _internal_test because none of these functions are public. import ( - "github.com/mmcdole/gofeed" - "github.com/stretchr/testify/assert" "testing" "time" + + "github.com/mmcdole/gofeed" + "github.com/stretchr/testify/assert" ) func Test_contains(t *testing.T) { @@ -76,7 +78,7 @@ func Test_podcasts_getNewestEpisode(t *testing.T) { fields fields want episode }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -97,7 +99,7 @@ func Test_podcasts_getOldestEpisode(t *testing.T) { fields fields want episode }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -118,7 +120,7 @@ func Test_podcasts_getRandomEpisode(t *testing.T) { fields fields want episode }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/content/podcast_test.go b/content/podcast_test.go index 2466109..76460db 100644 --- a/content/podcast_test.go +++ b/content/podcast_test.go @@ -1,8 +1,10 @@ +// nolint:TODO https://github.com/jmillerv/go-dj/issues/16 package content_test import ( - . "github.com/jmillerv/go-dj/content" "testing" + + . "github.com/jmillerv/go-dj/content" ) func TestPodcast_Get(t *testing.T) { @@ -17,7 +19,7 @@ func TestPodcast_Get(t *testing.T) { fields fields wantErr bool }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -44,7 +46,7 @@ func TestPodcast_Play(t *testing.T) { fields fields wantErr bool }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -70,7 +72,7 @@ func TestPodcast_Stop(t *testing.T) { name string fields fields }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/content/program.go b/content/program.go index 641ad83..be06136 100644 --- a/content/program.go +++ b/content/program.go @@ -12,6 +12,7 @@ type Program struct { Type MediaType } +//nolint:gochecknoglobals // the globals here help but a refactor would be considered. var ( PodcastPlayOrderRandom bool PodcastPlayerOrderOldest bool @@ -23,9 +24,11 @@ func (p *Program) getMediaType() MediaType { func (p *Program) GetMedia() Media { media := p.mediaFactory() + return media } +//nolint:forcetypeassert,gosimple,gocritic // type is checked in the switch case func (p *Program) mediaFactory() Media { m := MediaTypeMap[p.Type] switch m.(type) { @@ -33,33 +36,44 @@ func (p *Program) mediaFactory() Media { folder := m.(*Folder) folder.Name = p.Name folder.Path = p.Source + log.Debugf("returning Folder: %v", formatter.StructToString(folder)) + return folder case *LocalFile: file := m.(*LocalFile) file.Name = p.Name file.Path = p.Source + log.Debugf("returning LocalFile: %v", formatter.StructToString(file)) + return file case *Podcast: podcast := m.(*Podcast) podcast.Name = p.Name podcast.URL = p.Source + podcast.PlayOrder = playOrderNewest // default if PodcastPlayerOrderOldest == true { podcast.PlayOrder = playOrderOldest } + if PodcastPlayOrderRandom == true { podcast.PlayOrder = playOrderRandom } + log.Debugf("returning podcast: %v", formatter.StructToString(podcast)) + return podcast case *WebRadio: radio := m.(*WebRadio) radio.Name = p.Name radio.URL = p.Source + log.Debugf("returning WebRadio: %v", formatter.StructToString(radio)) + return radio } + return nil } diff --git a/content/program_test.go b/content/program_test.go index f1ddf20..191e8a8 100644 --- a/content/program_test.go +++ b/content/program_test.go @@ -1,9 +1,11 @@ +// nolint:TODO https://github.com/jmillerv/go-dj/issues/16 package content_test import ( + "testing" + . "github.com/jmillerv/go-dj/content" "github.com/stretchr/testify/assert" - "testing" ) func TestProgram_GetMedia(t *testing.T) { diff --git a/content/schedule.go b/content/schedule.go index 7fda7fe..8ec2cc5 100644 --- a/content/schedule.go +++ b/content/schedule.go @@ -1,7 +1,6 @@ package content import ( - "fmt" "math/rand" "os" "os/signal" @@ -17,6 +16,7 @@ import ( "github.com/spf13/viper" ) +//nolint:gochecknoglobals // the globals here help but a refactor would be considered. var Shuffled bool type Scheduler struct { @@ -29,12 +29,15 @@ type Scheduler struct { } } -func (s *Scheduler) Run() error { +func (s *Scheduler) Run() error { //nolint:godox,funlen,gocognit,cyclop,nolintlint // TODO: consider refactoring var wg sync.WaitGroup + log.Info("Starting Daemon") + // setup signal listeners sigchnl := make(chan os.Signal, 1) signal.Notify(sigchnl) + exitchnl := make(chan int) totalPrograms := len(s.Content.Programs) @@ -46,14 +49,18 @@ func (s *Scheduler) Run() error { // for loop that can be forced to continue from a go routine for _, p := range s.Content.Programs { now := time.Now() + log.Debugf("program %v", formatter.StructToIndentedString(p)) // if content is scheduled, retrieve and play scheduled := p.Timeslot.IsScheduledNow(now) - if scheduled { + if scheduled { //nolint:nestif // TODO: consider refactoring log.Infof("scheduler.Run::getting media type: %v", p.Type) + content := p.GetMedia() + log.Debugf("media struct: %v", content) + err := content.Get() // retrieve contents from file if err != nil { return err @@ -69,14 +76,18 @@ func (s *Scheduler) Run() error { // if p.getMediaType is webRadioContent or podcastContent start a timer and stop content from inside a go routine // because these are streams rather than files they behave differently from local content. + //nolint:gocritic,godox,nolintlint // TODO: refactor as a switch case and remove the nolint directive if p.getMediaType() == webRadioContent { go func() { duration := getDurationToEndTime(p.Timeslot.End) // might cause an index out of range issue stopCountDown(content, duration, &wg) }() + go func() { log.Info("playing web radio inside of a go routine") + wg.Add(1) + err = content.Play() if err != nil { log.WithError(err).Error("Run::content.Play") @@ -84,10 +95,11 @@ func (s *Scheduler) Run() error { }() } else if p.getMediaType() == podcastContent { go func() { - podcast := content.(*Podcast) + podcast := content.(*Podcast) //nolint:forcetypeassert // TODO: type checking log.Infof("podcast player duration %s", podcast.Player.duration) stopCountDown(content, podcast.Player.duration, &wg) }() + go func() { log.Info("playing podcast inside of a go routine") wg.Add(1) @@ -103,13 +115,17 @@ func (s *Scheduler) Run() error { } } } + log.Info("paused while go routines are running") + wg.Wait() // pause + if !p.Timeslot.IsScheduledNow(now) { log.WithField("IsScheduledNow", p.Timeslot.IsScheduledNow(now)). WithField("current time", time.Now(). Format(time.Kitchen)).Infof("media not scheduled") } + programIndex++ // increment index // check programs for scheduled content at regular interval @@ -121,12 +137,14 @@ func (s *Scheduler) Run() error { if err != nil { return err } + go func() { for { stop := <-sigchnl s.Stop(stop, nil) // passing nil because there is no media to stop. } }() + // pause the loop log.WithField("pause interval", s.Content.CheckInterval).Info("loop paused, will resume after pause interval") time.Sleep(interval) @@ -136,10 +154,11 @@ func (s *Scheduler) Run() error { exitcode := <-exitchnl os.Exit(exitcode) + return nil } -// Shuffle plays through the config content at random +// Shuffle plays through the config content at random. func (s *Scheduler) Shuffle() error { rand.Seed(time.Now().UnixNano()) rand.Shuffle(len(s.Content.Programs), @@ -149,7 +168,9 @@ func (s *Scheduler) Shuffle() error { // setup signal listeners sigchnl := make(chan os.Signal, 1) + signal.Notify(sigchnl) + exitchnl := make(chan int) for _, p := range s.Content.Programs { @@ -157,16 +178,19 @@ func (s *Scheduler) Shuffle() error { log.Infof("getting media type: %v", p.Type) content := p.GetMedia() log.Debugf("media struct: %v", content) + err := content.Get() if err != nil { return err } + go func() { for { stop := <-sigchnl s.Stop(stop, content) } }() + err = content.Play() if err != nil { return err @@ -175,31 +199,45 @@ func (s *Scheduler) Shuffle() error { exitcode := <-exitchnl os.Exit(exitcode) + return nil } func (s *Scheduler) Stop(signal os.Signal, media Media) { - if signal == syscall.SIGTERM { + if signal == syscall.SIGTERM { //nolint:nestif // TODO: consider refactoring log.Info("Got kill signal. ") + if media != nil { - media.Stop() + err := media.Stop() + if err != nil { + log.WithError(err).Error("scheduler.Stop::error stopping media") + } } + log.Info("Program will terminate now.") + os.Exit(0) } else if signal == syscall.SIGINT { if media != nil { - media.Stop() + err := media.Stop() + if err != nil { + log.WithError(err).Error("scheduler.Stop::error stopping media") + } } + log.Info("Got CTRL+C signal") + if media != nil { - media.Stop() + media.Stop() //nolint:errcheck } - fmt.Println("Closing.") + + log.Println("Closing.") + os.Exit(0) } } -func (s *Scheduler) getNextProgram(index int) *Program { +func (s *Scheduler) getNextProgram(index int) *Program { //nolint:unused return s.Content.Programs[index] } @@ -209,39 +247,47 @@ func NewScheduler(file string) (*Scheduler, error) { viper.SetConfigFile(file) viper.SetDefault("CheckInterval", "10m") // default Check Interval viper.SetDefault("PlayedPodcastTTL", "730h") // default Cache TTL 730h is ~1 month - if err := viper.ReadInConfig(); err != nil { + if err := viper.ReadInConfig(); err != nil { log.WithField("file", file).WithError(err).Error("Failed to read in config file") + return nil, err } + scheduler := new(Scheduler) if err := viper.Unmarshal(scheduler); err != nil { log.WithError(err).Error("unable to unmarshal config into struct") + return nil, err } + if scheduler.Content.Programs == nil { return nil, errors.New("scheduler is empty") - } log.Info("config loaded", formatter.StructToIndentedString(scheduler)) + return scheduler, nil } -// stopCountDown takes in a Media and duration and starts a ticker to stop the playing content +// stopCountDown takes in a Media and duration and starts a ticker to stop the playing content. func stopCountDown(content Media, period time.Duration, wg *sync.WaitGroup) { log.Infof("remaining time playing this stream %v", period) + t := time.NewTicker(period) defer t.Stop() - for { + + for { //nolint:gosimple // for select worked better here at the time of writing. select { case <-t.C: // call content.Stop log.Info("stopping content") + err := content.Stop() if err != nil { log.WithError(err).Error("stopCountDown::error stopping content") } + // typecast content as WebRadio webRadio, ok := content.(*WebRadio) if ok { @@ -250,6 +296,7 @@ func stopCountDown(content Media, period time.Duration, wg *sync.WaitGroup) { wg.Done() } } + // typecast content as Podcast podcast, ok := content.(*Podcast) if ok { @@ -257,14 +304,18 @@ func stopCountDown(content Media, period time.Duration, wg *sync.WaitGroup) { wg.Done() } } + log.Info("content stopped") + return } } } // getDurationToEndTime determines how much time in seconds needs to pass before the next program starts. -// TODO look at this function and timeslot.go's IsScheduleNow() and attempt to refactor to remove duplicate code. +// TODO: examine function and timeslot.go's IsScheduleNow(), attempt to refactor to remove duplicate code. +// +//nolint:godox //ignore the below line func getDurationToEndTime(currentProgramEnd string) time.Duration { current := time.Now() // get date info for string @@ -275,12 +326,16 @@ func getDurationToEndTime(currentProgramEnd string) time.Duration { dateString := strconv.Itoa(year) + "-" + strconv.Itoa(int(month)) + "-" + strconv.Itoa(day) // parse the date and the config time - // parsed times are returned in 2022-12-05 15:05:00 +0000 UTC format which doesn't appear to have a const in the time package + // parsed times are returned in 2022-12-05 15:05:00 +0000 UTC format + // which doesn't appear to have a const in the time package parsedProgramEnd, _ := dateparse.ParseAny(dateString + " " + currentProgramEnd) // matched parse time to fixed zone time - currentProgramEndTime := time.Date(parsedProgramEnd.Year(), parsedProgramEnd.Month(), parsedProgramEnd.Day(), parsedProgramEnd.Hour(), parsedProgramEnd.Minute(), parsedProgramEnd.Second(), parsedProgramEnd.Nanosecond(), current.Location()) + currentProgramEndTime := time.Date(parsedProgramEnd.Year(), parsedProgramEnd.Month(), parsedProgramEnd.Day(), + parsedProgramEnd.Hour(), parsedProgramEnd.Minute(), parsedProgramEnd.Second(), + parsedProgramEnd.Nanosecond(), current.Location()) duration := currentProgramEndTime.Sub(current) + return duration } diff --git a/content/schedule_test.go b/content/schedule_test.go index 7748fc4..e86d335 100644 --- a/content/schedule_test.go +++ b/content/schedule_test.go @@ -1,9 +1,11 @@ +// nolint:TODO https://github.com/jmillerv/go-dj/issues/16 package content import ( + "testing" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" - "testing" ) func TestNewScheduler(t *testing.T) { @@ -43,7 +45,8 @@ func TestNewScheduler(t *testing.T) { }, Type: MediaType("file"), }, - }}, + }, + }, }, }, wantErr: false, @@ -65,7 +68,7 @@ func TestNewScheduler(t *testing.T) { wantErr: true, }, } - // TODO make test pass when running in parallel and troubleshoot race condition. + //nolint:godox // TODO make test pass when running in parallel and troubleshoot race condition. for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := NewScheduler(tt.args.file) diff --git a/content/timeslot.go b/content/timeslot.go index cec299d..c592378 100644 --- a/content/timeslot.go +++ b/content/timeslot.go @@ -1,19 +1,20 @@ package content import ( - "github.com/araddon/dateparse" - log "github.com/sirupsen/logrus" "strconv" "time" + + "github.com/araddon/dateparse" + log "github.com/sirupsen/logrus" ) -// Times represents timeslots and are parsed in a 24hour format +// Times represents timeslots and are parsed in a 24hour format. type Timeslot struct { Begin string End string } -// IsScheduledNow checks the current time and returns a bool if the time falls within the range +// IsScheduledNow checks the current time and returns a bool if the time falls within the range. func (t *Timeslot) IsScheduledNow(current time.Time) bool { // get date info for string date := time.Date(current.Year(), current.Month(), current.Day(), 0, 0, 0, 0, current.Location()) @@ -23,21 +24,28 @@ func (t *Timeslot) IsScheduledNow(current time.Time) bool { dateString := strconv.Itoa(year) + "-" + strconv.Itoa(int(month)) + "-" + strconv.Itoa(day) // parse the date and the config time - // parsed times are returned in 2022-12-05 15:05:00 +0000 UTC format which doesn't appear to have a const in the time package + // parsed times are returned in 2022-12-05 15:05:00 +0000 UTC format + // which doesn't appear to have a const in the time package parsedStartTime, _ := dateparse.ParseAny(dateString + " " + t.Begin) parsedEndTime, _ := dateparse.ParseAny(dateString + " " + t.End) // matched parse time to fixed zone time - startTime := time.Date(parsedStartTime.Year(), parsedStartTime.Month(), parsedStartTime.Day(), parsedStartTime.Hour(), parsedStartTime.Minute(), parsedStartTime.Second(), parsedStartTime.Nanosecond(), current.Location()) - endTime := time.Date(parsedEndTime.Year(), parsedEndTime.Month(), parsedEndTime.Day(), parsedEndTime.Hour(), parsedEndTime.Minute(), parsedEndTime.Second(), parsedEndTime.Nanosecond(), current.Location()) + startTime := time.Date(parsedStartTime.Year(), parsedStartTime.Month(), parsedStartTime.Day(), + parsedStartTime.Hour(), parsedStartTime.Minute(), parsedStartTime.Second(), + parsedStartTime.Nanosecond(), current.Location()) + + endTime := time.Date(parsedEndTime.Year(), parsedEndTime.Month(), parsedEndTime.Day(), parsedEndTime.Hour(), + parsedEndTime.Minute(), parsedEndTime.Second(), parsedEndTime.Nanosecond(), current.Location()) return inTimeSpan(startTime, endTime, current) } func inTimeSpan(start, end, current time.Time) bool { - log.WithField("start", start).WithField("current", current).WithField("end", end).Info("timeslot::inTimeSpan: configured times") + log.WithField("start", start).WithField("current", current).WithField("end", end). + Info("timeslot::inTimeSpan: configured times") // handle scheduling that traverses days. tz, _ := time.LoadLocation("UTC") + if end.Before(start) && current.After(start) { return true } diff --git a/content/timeslot_test.go b/content/timeslot_test.go index 0d852f3..5db3023 100644 --- a/content/timeslot_test.go +++ b/content/timeslot_test.go @@ -1,10 +1,12 @@ +// nolint:TODO https://github.com/jmillerv/go-dj/issues/16 package content import ( - log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" "testing" "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" ) type TimeProvider interface { @@ -17,7 +19,7 @@ type testTime struct { func (testTime *testTime) Now() time.Time { tz, _ := time.LoadLocation("Local") - now := time.Date(2022, 12, 05, 23, 27, 0, 0, tz) + now := time.Date(2022, 12, 0o5, 23, 27, 0, 0, tz) log.Infof("testTime %v", now) return now } diff --git a/content/web_radio.go b/content/web_radio.go index 1ae7fdd..05e47e9 100644 --- a/content/web_radio.go +++ b/content/web_radio.go @@ -42,46 +42,56 @@ func (w *WebRadio) Get() error { webRadioStream.isPlaying = false w.Player = webRadioStream + return nil } func (w *WebRadio) Play() error { log.Infof("streaming from %v ", w.URL) + if !w.Player.isPlaying { err := w.Player.command.Start() if err != nil { return errors.Wrap(err, "error starting web radio streamPlayer") } + w.Player.isPlaying = true done := make(chan bool) + func() { w.Player.pipeChan <- w.Player.out done <- true }() <-done } + return nil } func (w *WebRadio) Stop() error { log.Infof("webradio.Stop::Stopping stream from %v ", w.URL) + if w.Player.isPlaying { w.Player.isPlaying = false + _, err := w.Player.in.Write([]byte("q")) if err != nil { log.WithError(err).Error("error stopping web radio streamPlayerName: w.Player.in.Write()") } + err = w.Player.in.Close() if err != nil { log.WithError(err).Error("error stopping web radio streamPlayerName: w.Player.in.Close()") } + err = w.Player.out.Close() if err != nil { log.WithError(err).Error("error stopping web radio streamPlayerName: w.Player.out.Close()") } - w.Player.command = nil + w.Player.command = nil w.Player.url = "" } + return nil } diff --git a/content/web_radio_test.go b/content/web_radio_test.go index d7dbef5..4d6eef1 100644 --- a/content/web_radio_test.go +++ b/content/web_radio_test.go @@ -1,8 +1,10 @@ +// nolint:TODO https://github.com/jmillerv/go-dj/issues/16 package content_test import ( - . "github.com/jmillerv/go-dj/content" "testing" + + . "github.com/jmillerv/go-dj/content" ) func TestWebRadio_Get(t *testing.T) { @@ -15,7 +17,7 @@ func TestWebRadio_Get(t *testing.T) { fields fields wantErr bool }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -40,7 +42,7 @@ func TestWebRadio_Play(t *testing.T) { fields fields wantErr bool }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -64,7 +66,7 @@ func TestWebRadio_Stop(t *testing.T) { name string fields fields }{ - // TODO: Add test cases. + //nolint:godox // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/main.go b/main.go index 07c5363..3eec78a 100644 --- a/main.go +++ b/main.go @@ -13,13 +13,14 @@ import ( ) const ( - configFile = "config.yml" - config_override = "GODJ_CONFIG_OVERRIDE" - logFile = "/tmp/godj.log" + configFile = "config.yml" + configOverride = "GODJ_CONFIG_OVERRIDE" + logFile = "/tmp/godj.log" + logPermissions = 0666 //nolint:gofumpt // gofumpt does weird things to this ) -func main() { - app := &cli.App{ +func main() { //nolint:funlen,cyclop // main function can be longer & more complex. + app := &cli.App{ //nolint:exhaustivestruct,exhaustruct Name: "Go DJ", Usage: "Daemon that schedules audio programming content", Version: "0.0.1", @@ -32,8 +33,8 @@ func main() { Action: func(c *cli.Context) { var config string log.Info("creating schedule from config") - if os.Getenv(config_override) != "" { - config = os.Getenv(config_override) + if os.Getenv(configOverride) != "" { + config = os.Getenv(configOverride) } else { config = configFile } @@ -59,6 +60,7 @@ func main() { if err != nil { log.WithError(err).Error("scheduler.Shuffle::unable to run go-dj") } + return } // run content normally @@ -68,21 +70,21 @@ func main() { } }, Flags: []cli.Flag{ - cli.BoolFlag{ + cli.BoolFlag{ //nolint:exhaustivestruct,exhaustruct Name: "random", Usage: "Start your radio station w/ randomized schedule", Required: false, Hidden: false, Destination: &content.Shuffled, }, - cli.BoolFlag{ + cli.BoolFlag{ //nolint:exhaustivestruct,exhaustruct Name: "pod-oldest", Usage: "podcasts will play starting with the oldest first", Required: false, Hidden: false, Destination: &content.PodcastPlayerOrderOldest, }, - cli.BoolFlag{ + cli.BoolFlag{ //nolint:exhaustivestruct,exhaustruct Name: "pod-random", Usage: "podcasts will play in a random order", Required: false, @@ -99,6 +101,9 @@ func main() { Action: func(c *cli.Context) { log.Info("clearing cache") scheduler, err := content.NewScheduler(configFile) + if err != nil { + log.WithError(err).Error("content.NewScheduler::unable to create scheduler from config file") + } ttl, err := time.ParseDuration(scheduler.Content.PlayedPodcastTTL) if err != nil { log.WithError(err).Error("unable to parse played podcast ttl") @@ -122,29 +127,33 @@ func main() { } } -// init sets global variables +// init sets global variables. func init() { content.Shuffled = false content.PodcastPlayerOrderOldest = false content.PodcastPlayOrderRandom = false + initLogger() } // initLogger creates the multiwriter, determines the log format for each destination, and sets the logfile location. -// at a later stage, it may be desirable to have different formats for standard out vs the log file. An example of how to do that can be found -// here https://github.com/sirupsen/logrus/issues/784#issuecomment-403765306 +// at a later stage, it may be desirable to have different formats for standard out vs the log file. +// An example of how to do that can be found here https://github.com/sirupsen/logrus/issues/784#issuecomment-403765306 func initLogger() { // create a new file for logs - logs, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + logs, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, logPermissions) if err != nil { log.WithError(err).Error("unable to open log file") } + // open the multiwriter multiWrite := io.MultiWriter(os.Stdout, logs) - log.SetFormatter(&log.TextFormatter{ + + log.SetFormatter(&log.TextFormatter{ //nolint:exhaustruct // don't need this full enumerated ForceColors: true, FullTimestamp: true, TimestampFormat: time.RFC822, }) + log.SetOutput(multiWrite) } diff --git a/taskfile.yaml b/taskfile.yaml index 99f43f4..6b4d05e 100644 --- a/taskfile.yaml +++ b/taskfile.yaml @@ -4,6 +4,7 @@ tasks: # Builds the go-dj binary build: cmds: + - task: lint - go build -v -o go-dj . - chmod +x go-dj @@ -11,6 +12,7 @@ tasks: install_deps: cmds: - sudo apt install libasound2-dev libudev-dev pkg-config + - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.53.3 # Runs go-dj run: @@ -18,6 +20,11 @@ tasks: - task: build - ./go-dj --help + # Lints go-dj + lint: + cmds: + - golangci-lint run ./... + # Run the tests test: cmds: