// Copyright (C) 2020 Raziman package main import ( "bytes" "fmt" "io/ioutil" "log" "os" "os/exec" "path" "path/filepath" "strings" "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/spf13/viper" spin "github.com/tj/go-spin" "github.com/ztrue/tracerr" ) // Playlist and mp3 files are represented with this struct // if isAudioFile equals to false it is a directory type AudioFile struct { name string path string isAudioFile bool length time.Duration node *tview.TreeNode parent *tview.TreeNode } // Playlist struct represents playlist panel // that shows the tree of the music directory type Playlist struct { *tview.TreeView prevNode *tview.TreeNode defaultTitle string // number of downloads download int done chan struct{} } func (p *Playlist) help() []string { return []string{ "j down", "k up", "h close node", "a create a playlist", "l add song to queue", "L add playlist to queue", "d delete file from filesystem", "D delete playlist from filesystem", "Y download audio", "r refresh", "R rename", "/ find in playlist", } } // newPlaylist returns new instance of playlist and runs populate function // on root music directory. func newPlaylist(args Args) *Playlist { rootDir, err := filepath.Abs(expandTilde(viper.GetString("general.music_dir"))) // if not default value was given if *args.music != "~/music" { rootDir = expandFilePath(*args.music) } if err != nil { log.Fatalf("Unable to find music directory: %e", err) } rootTextView := fmt.Sprintf("%s %s", viper.GetString("emoji.playlist"), path.Base(rootDir)) root := tview.NewTreeNode(rootTextView). SetColor(gomu.colors.accent) tree := tview.NewTreeView().SetRoot(root) playlist := &Playlist{ TreeView: tree, defaultTitle: "─ Playlist ──┤ 0 downloads ├", done: make(chan struct{}), } rootAudioFile := &AudioFile{ name: path.Base(rootDir), node: root, path: rootDir, } root.SetReference(rootAudioFile) playlist. SetTitle(playlist.defaultTitle). SetBorder(true). SetTitleAlign(tview.AlignLeft). SetBorderPadding(0, 0, 1, 1) populate(root, rootDir) var firstChild *tview.TreeNode if len(root.GetChildren()) == 0 { firstChild = root } else { firstChild = root.GetChildren()[0] } playlist.setHighlight(firstChild) playlist.SetChangedFunc(func(node *tview.TreeNode) { playlist.setHighlight(node) }) playlist.SetSelectedFunc(func(node *tview.TreeNode) { node.SetExpanded(!node.IsExpanded()) }) playlist.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { cmds := map[rune]string{ 'a': "create_playlist", 'D': "delete_playlist", 'd': "delete_file", 'Y': "download_audio", 'l': "add_queue", 'L': "bulk_add", 'h': "close_node", 'r': "refresh", 'R': "rename", '/': "playlist_search", } for key, cmd := range cmds { if e.Rune() != key { continue } fn, err := gomu.command.getFn(cmd) if err != nil { logError(err) return e } fn() } // disable default key handler for space if e.Rune() == ' ' { return nil } return e }) return playlist } // Returns the current file highlighted in the playlist func (p Playlist) getCurrentFile() *AudioFile { node := p.GetCurrentNode() if node == nil { return nil } return node.GetReference().(*AudioFile) } // Deletes song from filesystem func (p *Playlist) deleteSong(audioFile *AudioFile) (err error) { confirmationPopup( "Are you sure to delete this audio file?", func(_ int, buttonName string) { if buttonName == "no" || buttonName == "" { return } err := os.Remove(audioFile.path) if err != nil { defaultTimedPopup(" Error ", "Unable to delete "+audioFile.name) err = tracerr.Wrap(err) } else { defaultTimedPopup(" Success ", audioFile.name+"\nhas been deleted successfully") p.refresh() } }) return nil } // Deletes playlist/dir from filesystem func (p *Playlist) deletePlaylist(audioFile *AudioFile) (err error) { var selectedDir *AudioFile // gets the parent dir if current focused node is not a dir if audioFile.isAudioFile { selectedDir = audioFile.parent.GetReference().(*AudioFile) } else { selectedDir = audioFile } confirmationPopup("Are you sure to delete this directory?", func(_ int, buttonName string) { if buttonName == "no" || buttonName == "" { return } err := os.RemoveAll(selectedDir.path) if err != nil { defaultTimedPopup( " Error ", "Unable to delete dir "+selectedDir.name) err = tracerr.Wrap(err) } else { defaultTimedPopup( " Success ", selectedDir.name+"\nhas been deleted successfully") p.refresh() } }) return nil } // Bulk add a playlist to queue func (p *Playlist) addAllToQueue(root *tview.TreeNode) { var childrens []*tview.TreeNode childrens = root.GetChildren() // gets the parent if the highlighted item is a file if root.GetReference().(*AudioFile).isAudioFile { childrens = root.GetReference().(*AudioFile).parent.GetChildren() } for _, v := range childrens { currNode := v.GetReference().(*AudioFile) gomu.queue.enqueue(currNode) } } // Refreshes the playlist and read the whole root music dir func (p *Playlist) refresh() { root := gomu.playlist.GetRoot() prevFileName := gomu.playlist.GetCurrentNode().GetText() root.ClearChildren() populate(root, root.GetReference().(*AudioFile).path) root.Walk(func(node, parent *tview.TreeNode) bool { // to preserve previously highlighted node if node.GetText() == prevFileName { p.setHighlight(node) return false } return true }) } // Adds child while setting reference to audio file func (p *Playlist) addSongToPlaylist( audioPath string, selPlaylist *tview.TreeNode, ) error { f, err := os.Open(audioPath) if err != nil { return tracerr.Wrap(err) } defer f.Close() songName := getName(audioPath) node := tview.NewTreeNode(songName) audioLength, err := getLength(audioPath) if err != nil { return tracerr.Wrap(err) } audioFile := &AudioFile{ name: songName, path: audioPath, isAudioFile: true, length: audioLength, node: node, parent: selPlaylist, } displayText := songName if viper.GetBool("general.emoji") { displayText = fmt.Sprintf("🎵 %s", songName) } node.SetReference(audioFile) node.SetText(displayText) selPlaylist.AddChild(node) return nil } // Gets all audio files walks from music root directory func (p *Playlist) getAudioFiles() []*AudioFile { root := p.GetRoot() audioFiles := []*AudioFile{} root.Walk(func(node, _ *tview.TreeNode) bool { audioFile := node.GetReference().(*AudioFile) audioFiles = append(audioFiles, audioFile) return true }) return audioFiles } // Creates a directory under selected node, returns error if playlist exists func (p *Playlist) createPlaylist(name string) error { selectedNode := p.GetCurrentNode() parentNode := selectedNode.GetReference().(*AudioFile).parent // if the current node is the root // sets the parent to itself if parentNode == nil { parentNode = selectedNode } audioFile := parentNode.GetReference().(*AudioFile) err := os.Mkdir(path.Join(audioFile.path, name), 0744) if err != nil { return tracerr.Wrap(err) } p.refresh() return nil } // This is used to replace default behaviour of SetCurrentNode which // adds color highlight attributes func (p *Playlist) setHighlight(currNode *tview.TreeNode) { if p.prevNode != nil { p.prevNode.SetColor(gomu.colors.background) } currNode.SetColor(gomu.colors.accent) p.SetCurrentNode(currNode) if currNode.GetReference().(*AudioFile).isAudioFile { p.prevNode = currNode } } // Traverses the playlist and finds the AudioFile struct // audioName must be hashed with sha1 first func (p *Playlist) findAudioFile(audioName string) (*AudioFile, error) { root := p.GetRoot() var selNode *AudioFile root.Walk(func(node, _ *tview.TreeNode) bool { audioFile := node.GetReference().(*AudioFile) hashed := sha1Hex(getName(audioFile.name)) if hashed == audioName { selNode = audioFile return false } return true }) if selNode == nil { return nil, tracerr.New("no matching audio name") } return selNode, nil } // Highlight the selected node searched using fzf func (p *Playlist) fuzzyFind() error { var result string var err error audioFiles := p.getAudioFiles() paths := make(map[string]*tview.TreeNode, len(audioFiles)) input := make([]string, 0, len(audioFiles)) for _, v := range audioFiles { rootDir := audioFiles[0].path + "/" // path relative to music directory shortPath := strings.TrimPrefix(v.path, rootDir) paths[shortPath] = v.node input = append(input, shortPath) } gomu.suspend() ok := gomu.app.Suspend(func() { res, e := fzfFind(input) if e != nil { err = tracerr.Wrap(e) } result = res }) gomu.unsuspend() if err != nil { return tracerr.Wrap(err) } if !ok { return tracerr.New("App was not suspended") } if result == "" { return nil } if err != nil { return tracerr.Wrap(err) } var selNode *tview.TreeNode selNode, ok = paths[result] if ok { p.setHighlight(selNode) } return nil } func (p *Playlist) rename(newName string) error { currentNode := p.GetCurrentNode() audio := currentNode.GetReference().(*AudioFile) pathToFile, _ := filepath.Split(audio.path) var newPath string if audio.isAudioFile { newPath = pathToFile + newName + ".mp3" } else { newPath = pathToFile + newName } err := os.Rename(audio.path, newPath) if err != nil { return tracerr.Wrap(err) } return nil } // updateTitle creates a spinning motion on the title // of the playlist panel when downloading. func (p *Playlist) updateTitle() { if p.download == 0 { p.SetTitle(p.defaultTitle) return } // only one call can be made in one time if p.download > 1 { return } s := spin.New() Download: for { if gomu.isSuspend { continue } select { case <-p.done: p.download -= 1 if p.download == 0 { p.SetTitle(p.defaultTitle) break Download } case <-time.After(time.Millisecond * 100): r, g, b := gomu.colors.accent.RGB() hexColor := padHex(r, g, b) title := fmt.Sprintf("─ Playlist ──┤ %d downloads [green]%s[#%s] ├", p.download, s.Next(), hexColor) p.SetTitle(title) gomu.app.Draw() } } } // Takes a list of input and suspends tview // returns empty string if cancelled func fzfFind(input []string) (string, error) { var in strings.Builder var out strings.Builder for _, v := range input { in.WriteString(v + "\n") } cmd := exec.Command("fzf") cmd.Stdin = strings.NewReader(in.String()) cmd.Stderr = os.Stderr cmd.Stdout = &out if err := cmd.Run(); cmd.ProcessState.ExitCode() == 130 { // exit code 130 is when we cancel FZF // not an error return "", nil } else if err != nil { return "", fmt.Errorf("failed to find a file: %s", err) } f := strings.TrimSpace(out.String()) return f, nil } // Download audio from youtube audio and adds the song to the selected playlist func ytdl(url string, selPlaylist *tview.TreeNode) error { // lookup if youtube-dl exists _, err := exec.LookPath("youtube-dl") if err != nil { defaultTimedPopup(" Error ", "youtube-dl is not in your $PATH") return tracerr.Wrap(err) } dir := viper.GetString("general.music_dir") selAudioFile := selPlaylist.GetReference().(*AudioFile) selPlaylistName := selAudioFile.name defaultTimedPopup(" Ytdl ", "Downloading") // specify the output path for ytdl outputDir := fmt.Sprintf( "%s/%s/%%(title)s.%%(ext)s", dir, selPlaylistName) args := []string{ "--extract-audio", "--audio-format", "mp3", "--output", outputDir, url, } cmd := exec.Command("youtube-dl", args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr gomu.playlist.download++ go gomu.playlist.updateTitle() // blocking err = cmd.Run() gomu.playlist.done <- struct{}{} if err != nil { defaultTimedPopup(" Error ", "Error running youtube-dl") return tracerr.Wrap(err) } playlistPath := path.Join(expandTilde(dir), selPlaylistName) audioPath := extractFilePath(stdout.Bytes(), playlistPath) err = appendFile(expandTilde(viper.GetString("general.history_path")), url+"\n") if err != nil { return tracerr.Wrap(err) } err = gomu.playlist.addSongToPlaylist(audioPath, selPlaylist) if err != nil { return tracerr.Wrap(err) } downloadFinishedMessage := fmt.Sprintf("Finished downloading\n%s", getName(audioPath)) defaultTimedPopup(" Ytdl ", downloadFinishedMessage) return nil } // Add songs and their directories in Playlist panel func populate(root *tview.TreeNode, rootPath string) error { files, err := ioutil.ReadDir(rootPath) if err != nil { return tracerr.Wrap(err) } for _, file := range files { path := filepath.Join(rootPath, file.Name()) f, err := os.Open(path) if err != nil { continue } defer f.Close() songName := getName(file.Name()) child := tview.NewTreeNode(songName) if !file.IsDir() { filetype, err := getFileContentType(f) if err != nil { continue } // skip if not mp3 file if filetype != "mpeg" { continue } audioLength, err := getLength(path) if err != nil { continue } audioFile := &AudioFile{ name: songName, path: path, isAudioFile: true, length: audioLength, node: child, parent: root, } displayText := songName if viper.GetBool("general.emoji") { displayText = fmt.Sprintf(" %s %s", viper.GetString("emoji.file"), songName) } child.SetReference(audioFile) child.SetText(displayText) root.AddChild(child) } if file.IsDir() { audioFile := &AudioFile{ name: songName, path: path, node: child, parent: root, } displayText := songName if viper.GetBool("general.emoji") { displayText = fmt.Sprintf(" %s %s", viper.GetString("emoji.playlist"), songName) } child.SetReference(audioFile) child.SetColor(gomu.colors.accent) child.SetText(displayText) root.AddChild(child) populate(child, path) } } return nil }