diff --git a/go.mod b/go.mod index 8b0e765..e01853b 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mattn/go-sqlite3 v1.14.12 // indirect github.com/mewkiz/flac v1.0.7 // indirect github.com/mewkiz/pkg v0.0.0-20211102230744-16a6ce8f1b77 // indirect github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect diff --git a/go.sum b/go.sum index 5076b1c..537c85b 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0= +github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mewkiz/flac v1.0.7 h1:uIXEjnuXqdRaZttmSFM5v5Ukp4U6orrZsnYGGR3yow8= github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= diff --git a/main.go b/main.go index 29d8c6d..92087a8 100644 --- a/main.go +++ b/main.go @@ -13,8 +13,9 @@ func main() { log.Fatal("Expected a path to some music") } - p := tea.NewProgram(gomus.NewModel(gomus.ModelArgs{ + p := tea.NewProgram(gomus.NewModel(gomus.ModelConfig{ MusicPath: os.Args[1], + GomusPath: ".gomus", })) if err := p.Start(); err != nil { diff --git a/pkg/commands.go b/pkg/commands.go index 66bd251..4c17fbe 100644 --- a/pkg/commands.go +++ b/pkg/commands.go @@ -31,3 +31,13 @@ func newTrackVolumeCmd(volume float64) tea.Cmd { return trackVolumeMsg{volume} } } + +type libraryUpdateMsg struct { + tracks []track +} + +func newLibraryUpdateCmd(tracks []track) tea.Cmd { + return func() tea.Msg { + return libraryUpdateMsg{tracks} + } +} diff --git a/pkg/list_delegate.go b/pkg/list_delegate.go index d01dcef..db33961 100644 --- a/pkg/list_delegate.go +++ b/pkg/list_delegate.go @@ -24,7 +24,7 @@ func (d trackListDelegate) Render(w io.Writer, m list.Model, index int, listItem return } - fmt.Fprintf(w, f) + fmt.Fprintf(w, fmt.Sprintf(" %s", f)) } func newTrackListDelegate() trackListDelegate { diff --git a/pkg/model.go b/pkg/model.go index f39e1c7..afc5aab 100644 --- a/pkg/model.go +++ b/pkg/model.go @@ -1,10 +1,11 @@ package gomus import ( - "time" + "errors" + "os" tea "github.com/charmbracelet/bubbletea" - "github.com/faiface/beep/speaker" + "github.com/faiface/beep" ) var ( @@ -12,8 +13,9 @@ var ( termHeight = 0 ) -type ModelArgs struct { +type ModelConfig struct { MusicPath string + GomusPath string } type Model struct { @@ -23,28 +25,32 @@ type Model struct { TrackPlayer TrackPlayerEffects trackIndex - trackPlayerView } -func NewModel(args ModelArgs) Model { - ti := NewDirTrackIndex(args.MusicPath) - tpv := newTrackPlayerView(ti.tracks) +func NewModel(cfg ModelConfig) Model { + if _, err := os.Stat(cfg.GomusPath); errors.Is(err, os.ErrNotExist) { + err := os.Mkdir(cfg.GomusPath, 0755) + check(err) + } return Model{ cursor: 0, currentlyPlaying: 0, - trackIndex: ti, - TrackPlayer: TrackPlayer{}, - TrackPlayerEffects: newTrackPlayerEffects(), - - trackPlayerView: tpv, + trackIndex: NewDirTrackIndex(cfg), + TrackPlayer: NewTrackPlayer(), + TrackPlayerEffects: NewTrackPlayerEffects(), + trackPlayerView: NewTrackPlayerView(), } } func (m Model) Init() tea.Cmd { - return tea.Batch(tea.EnterAltScreen, m.trackPlayerView.Init()) + var cmds []tea.Cmd + cmds = append(cmds, tea.EnterAltScreen) + cmds = append(cmds, m.trackPlayerView.Init()) + cmds = append(cmds, newLibraryUpdateCmd(m.trackIndex.tracks)) + return tea.Batch(cmds...) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -57,7 +63,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": - m.TrackPlayer.Close() return m, tea.Quit case "enter": t := m.trackPlayerView.trackList.SelectedItem().(track) @@ -65,35 +70,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { stream, format, err := t.GetStream() check(err) - speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)) + resampled := beep.Resample(4, format.SampleRate, m.TrackPlayer.SampleRate, stream) + m.TrackPlayer.Play(resampled) - m.TrackPlayer.Play(&stream, &m.TrackPlayerEffects) cmds = append(cmds, newTrackChangeCmd(t)) case " ": - if m.TrackPlayer.playerCtrl != nil { - s := m.TrackPlayer.TogglePause() - cmds = append(cmds, newTrackPauseCmd(s)) - } + s := m.TrackPlayer.TogglePause() + cmds = append(cmds, newTrackPauseCmd(s)) case "-", "=": - v := &m.TrackPlayerEffects.volume - + pe := &m.TrackPlayerEffects if msg.String() == "-" { - *v -= 0.1 - if *v < minVolume { - *v = minVolume + pe.volume -= 0.1 + if pe.volume < minVolume { + pe.volume = minVolume } } else { - *v += 0.1 - if *v > maxVolume { - *v = maxVolume + pe.volume += 0.1 + if pe.volume > maxVolume { + pe.volume = maxVolume } } - if m.TrackPlayer.playerVol != nil { - m.TrackPlayer.SetVolume(*v) - } + m.TrackPlayer.SetVolume(pe.volume) - cmds = append(cmds, newTrackVolumeCmd(*v)) + cmds = append(cmds, newTrackVolumeCmd(m.TrackPlayerEffects.volume)) } } diff --git a/pkg/track.go b/pkg/track.go new file mode 100644 index 0000000..232d45c --- /dev/null +++ b/pkg/track.go @@ -0,0 +1,74 @@ +package gomus + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/faiface/beep" + bflac "github.com/faiface/beep/flac" + "github.com/mewkiz/flac" + "github.com/mewkiz/flac/meta" +) + +type track struct { + Title string + Album string + Artist string + TrackPath string + TrackTotal uint8 + TrackNumber uint8 +} + +func (t track) FilterValue() string { return "" } +func (t track) fullName() string { return fmt.Sprintf("%s - %s", t.Artist, t.Title) } + +func (t track) GetStream() (beep.StreamSeekCloser, beep.Format, error) { + f, err := os.Open(t.TrackPath) + check(err) + + if strings.HasSuffix(t.TrackPath, ".flac") { + streamer, format, err := bflac.Decode(f) + check(err) + return streamer, format, nil + } + + return nil, beep.Format{}, fmt.Errorf("Could not parse track") +} + +func TrackFromFlac(path string) track { + path, err := filepath.Abs(path) + check(err) + + s, err := flac.ParseFile(path) + check(err) + + t := track{TrackPath: path} + for _, block := range s.Blocks { + if block.Header.Type == meta.TypeVorbisComment { + c := block.Body.(*meta.VorbisComment) + for _, tagTuple := range c.Tags { + tag, val := tagTuple[0], tagTuple[1] + switch tag { + case "TITLE": + t.Title = val + case "ARTIST": + t.Artist = val + case "ALBUM": + t.Album = val + case "TRACKNUMBER": + trackNum, err := strconv.ParseUint(val, 10, 8) + check(err) + t.TrackNumber = uint8(trackNum) + case "TRACKTOTAL": + trackTotal, err := strconv.ParseUint(val, 10, 8) + check(err) + t.TrackTotal = uint8(trackTotal) + } + } + } + } + return t +} diff --git a/pkg/track_fetcher.go b/pkg/track_fetcher.go index 335c1d5..bb96a47 100644 --- a/pkg/track_fetcher.go +++ b/pkg/track_fetcher.go @@ -1,76 +1,80 @@ package gomus import ( - "fmt" - "io/ioutil" - "os" + "database/sql" + "io/fs" + "log" "path/filepath" "strings" - "github.com/faiface/beep" - bflac "github.com/faiface/beep/flac" - "github.com/mewkiz/flac" - "github.com/mewkiz/flac/meta" + _ "github.com/mattn/go-sqlite3" ) -type track struct { - Name string - Artist string - TrackPath string -} - -func (t track) FilterValue() string { return "" } -func (t track) fullName() string { return fmt.Sprintf("%s - %s", t.Artist, t.Name) } - -func (t track) GetStream() (beep.StreamSeekCloser, beep.Format, error) { - f, err := os.Open(t.TrackPath) - check(err) - - if strings.HasSuffix(t.TrackPath, ".flac") { - streamer, format, err := bflac.Decode(f) - check(err) - return streamer, format, nil - } - - return nil, beep.Format{}, fmt.Errorf("Could not parse track") -} - -func TrackFromFlac(path string) track { - s, err := flac.ParseFile(path) - check(err) - - t := track{TrackPath: path} - for _, block := range s.Blocks { - if block.Header.Type == meta.TypeVorbisComment { - c := block.Body.(*meta.VorbisComment) - for _, tag := range c.Tags { - if tag[0] == "ARTIST" { - t.Artist = tag[1] - } else if tag[0] == "TITLE" { - t.Name = tag[1] - } - } - } - } - - return t -} - type trackIndex struct { tracks []track + db *sql.DB } -func NewDirTrackIndex(path string) trackIndex { - files, err := ioutil.ReadDir(path) - check(err) - - tracks := []track{} - for _, file := range files { - if !file.IsDir() && strings.HasSuffix(file.Name(), ".flac") { - p := filepath.Join(path, file.Name()) - tracks = append(tracks, TrackFromFlac(p)) - } +func NewDirTrackIndex(cfg ModelConfig) trackIndex { + dbPath := filepath.Join(cfg.GomusPath, "gomus.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + log.Fatalf("Failed to open gomus database: %v", err) } - return trackIndex{tracks: tracks} + tblStmt := ` + CREATE TABLE IF NOT EXISTS track ( + title TEXT NOT NULL, + album TEXT, + artist TEXT NOT NULL, + uri TEXT NOT NULL, + trackNumber INTEGER, + trackTotal INTEGER + ); + ` + + _, err = db.Exec(tblStmt) + if err != nil { + log.Fatalf("%q: %s\n", err, tblStmt) + } + + path := cfg.MusicPath + tracks, err := ScanDirectory(path) + check(err) + + ti := trackIndex{tracks, db} + ti.IndexTracks(tracks) + + return ti +} + +func (ti trackIndex) IndexTracks(tracks []track) error { + tx, err := ti.db.Begin() + check(err) + + stmt, err := tx.Prepare("insert into track(title, album, artist, uri, trackNumber, trackTotal) values (?, ?, ?, ?, ?, ?)") + check(err) + + for _, t := range tracks { + stmt.Exec(t.Title, t.Album, t.Artist, t.TrackPath, t.TrackNumber, t.TrackTotal) + } + + err = tx.Commit() + check(err) + + return nil +} + +func ScanDirectory(path string) ([]track, error) { + tracks := []track{} + filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { + check(err) + if !d.IsDir() && strings.HasSuffix(d.Name(), ".flac") { + t := TrackFromFlac(path) + tracks = append(tracks, t) + } + return nil + }) + + return tracks, nil } diff --git a/pkg/track_player.go b/pkg/track_player.go index 7168396..7e7538d 100644 --- a/pkg/track_player.go +++ b/pkg/track_player.go @@ -1,6 +1,8 @@ package gomus import ( + "time" + "github.com/faiface/beep" "github.com/faiface/beep/effects" "github.com/faiface/beep/speaker" @@ -17,44 +19,51 @@ type TrackPlayerEffects struct { volume float64 } -func newTrackPlayerEffects() TrackPlayerEffects { +func NewTrackPlayerEffects() TrackPlayerEffects { return TrackPlayerEffects{volume: startVolume} } type TrackPlayer struct { - streamer *beep.StreamSeekCloser - playerCtrl *beep.Ctrl - playerVol *effects.Volume + *beep.Ctrl + *effects.Volume + + beep.SampleRate } -func (t *TrackPlayer) Play(streamer *beep.StreamSeekCloser, trackEffects *TrackPlayerEffects) { - if t.streamer != nil { - t.Close() +func NewTrackPlayer() TrackPlayer { + sr := beep.SampleRate(44100) + speaker.Init(sr, sr.N(time.Second/10)) + + ctrl := &beep.Ctrl{Streamer: beep.Silence(1), Paused: false} + volume := &effects.Volume{Streamer: ctrl, Base: base, Volume: startVolume, Silent: false} + + return TrackPlayer{ + SampleRate: sr, + Ctrl: ctrl, + Volume: volume, } +} - ctrl := &beep.Ctrl{Streamer: beep.Loop(-1, *streamer), Paused: false} - volume := &effects.Volume{Streamer: ctrl, Base: base, Volume: trackEffects.volume, Silent: false} - speaker.Play(volume) +func (t *TrackPlayer) Play(streamer beep.Streamer) { + speaker.Clear() - t.streamer = streamer - t.playerCtrl = ctrl - t.playerVol = volume + speaker.Lock() + t.Ctrl.Streamer = streamer + speaker.Unlock() + + speaker.Play(t.Volume) } func (t *TrackPlayer) TogglePause() bool { speaker.Lock() - newState := !t.playerCtrl.Paused - t.playerCtrl.Paused = newState + newState := !t.Ctrl.Paused + t.Ctrl.Paused = newState speaker.Unlock() return newState } func (t *TrackPlayer) SetVolume(volume float64) { speaker.Lock() - (*t.playerVol).Volume = volume + t.Volume.Volume = volume speaker.Unlock() } - -func (t *TrackPlayer) Close() { - (*t.streamer).Close() -} diff --git a/pkg/track_player_view.go b/pkg/track_player_view.go index 87e2cb7..6f55561 100644 --- a/pkg/track_player_view.go +++ b/pkg/track_player_view.go @@ -11,20 +11,15 @@ type trackPlayerView struct { statusBar } -func newTrackPlayerView(tracks []track) trackPlayerView { - c := mapList(tracks, func(t track) list.Item { - return t - }) - - l := list.New(c, newTrackListDelegate(), 0, 0) +func NewTrackPlayerView() trackPlayerView { + l := list.New([]list.Item{}, newTrackListDelegate(), 0, 0) l.SetShowTitle(false) l.SetShowStatusBar(false) l.SetShowHelp(false) l.SetFilteringEnabled(false) - + l.SetShowPagination(false) s := statusBar{currentVolume: startVolume} - - return trackPlayerView{trackList: l, statusBar: s} + return trackPlayerView{statusBar: s, trackList: l} } func (v trackPlayerView) Init() tea.Cmd { @@ -42,6 +37,11 @@ func (v trackPlayerView) Update(msg tea.Msg) (trackPlayerView, tea.Cmd) { case tea.WindowSizeMsg: v.trackList.SetHeight(msg.Height - 2) v.trackList.SetWidth(msg.Width) + case libraryUpdateMsg: + c := MapList(msg.tracks, func(t track) list.Item { + return t + }) + v.trackList.SetItems(c) } var cmd tea.Cmd