gomu/playlist.go
2020-07-24 14:53:17 +08:00

703 lines
13 KiB
Go

// Copyright (C) 2020 Raziman
package main
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
"github.com/gdamore/tcell"
"github.com/rivo/tview"
"github.com/spf13/viper"
"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
}
// Treeview of a music directory
type Playlist struct {
*tview.TreeView
prevNode *tview.TreeNode
}
func (p *Playlist) help() []string {
return []string{
"j down",
"k up",
"h close node",
"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",
"f find in playlist",
}
}
func newPlaylist() *Playlist {
rootDir, err := filepath.Abs(expandTilde(viper.GetString("music_dir")))
if err != nil {
logError(err)
}
root := tview.NewTreeNode(path.Base(rootDir)).
SetColor(gomu.accentColor)
tree := tview.NewTreeView().SetRoot(root)
playlist := &Playlist{
TreeView: tree,
}
rootAudioFile := &AudioFile{
name: root.GetText(),
node: root,
path: rootDir,
}
root.SetReference(rootAudioFile)
playlist.
SetTitle(" Playlist ").
SetBorder(true).
SetTitleAlign(tview.AlignLeft)
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 {
currNode := playlist.GetCurrentNode()
audioFile := currNode.GetReference().(*AudioFile)
switch e.Rune() {
case ' ':
// Disable default key handler
return nil
case 'a':
name, _ := gomu.pages.GetFrontPage()
if name != "mkdir-popup" {
createPlaylistPopup()
}
case 'D':
err := playlist.deletePlaylist(audioFile)
if err != nil {
logError(err)
}
case 'd':
// prevent from deleting a directory
if !audioFile.isAudioFile {
return e
}
err := playlist.deleteSong(audioFile)
if err != nil {
logError(err)
}
case 'Y':
if gomu.pages.HasPage("download-popup") {
gomu.pages.RemovePage("download-popup")
return e
}
// this ensures it downloads to
// the correct dir
if audioFile.isAudioFile {
downloadMusicPopup(audioFile.parent)
} else {
downloadMusicPopup(currNode)
}
case 'l':
if audioFile.isAudioFile {
gomu.queue.enqueue(audioFile)
} else {
currNode.SetExpanded(true)
}
case 'h':
// if closing node with no children
// close the node's parent
// remove the color of the node
if audioFile.isAudioFile {
parent := audioFile.parent
playlist.setHighlight(parent)
parent.SetExpanded(false)
}
currNode.Collapse()
case 'L':
if !viper.GetBool("confirm_bulk_add") {
playlist.addAllToQueue(playlist.GetCurrentNode())
return e
}
confirmationPopup(
"Are you sure to add this whole directory into queue?",
func(_ int, label string) {
if label == "yes" {
playlist.addAllToQueue(playlist.GetCurrentNode())
}
gomu.pages.RemovePage("confirmation-popup")
gomu.app.SetFocus(playlist)
})
case 'r':
playlist.refresh()
case 'f':
err := playlist.fuzzyFind()
if err != nil {
logError(err)
}
}
return e
})
return playlist
}
// 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 == "" {
gomu.pages.RemovePage("confirmation-popup")
gomu.popups.pop()
return
}
err := os.Remove(audioFile.path)
if err != nil {
timedPopup(" Error ", "Unable to delete "+audioFile.name,
getPopupTimeout(), 0, 0)
err = tracerr.Wrap(err)
} else {
timedPopup(" Success ", audioFile.name+"\nhas been deleted successfully",
getPopupTimeout(), 0, 0)
p.refresh()
}
gomu.pages.RemovePage("confirmation-popup")
gomu.popups.pop()
})
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 == "" {
gomu.pages.RemovePage("confirmation-popup")
gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive))
return
}
err := os.RemoveAll(selectedDir.path)
if err != nil {
timedPopup(
" Error ",
"Unable to delete dir "+selectedDir.name,
getPopupTimeout(), 0, 0)
err = tracerr.Wrap(err)
} else {
timedPopup(
" Success ",
selectedDir.name+"\nhas been deleted successfully",
getPopupTimeout(), 0, 0)
p.refresh()
}
gomu.pages.RemovePage("confirmation-popup")
gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive))
})
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()
node := tview.NewTreeNode(getName(audioPath))
audioLength, err := getLength(audioPath)
if err != nil {
return tracerr.Wrap(err)
}
audioFile := &AudioFile{
name: path.Base(audioPath),
path: audioPath,
isAudioFile: true,
length: audioLength,
parent: selPlaylist,
}
node.SetReference(audioFile)
selPlaylist.AddChild(node)
gomu.app.Draw()
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.textColor)
}
currNode.SetColor(gomu.accentColor)
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 = paths[result]
p.setHighlight(selNode)
return nil
}
// 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 {
timedPopup(" Error ", "youtube-dl is not in your $PATH",
getPopupTimeout(), 0, 0)
return tracerr.Wrap(err)
}
dir := viper.GetString("music_dir")
selAudioFile := selPlaylist.GetReference().(*AudioFile)
selPlaylistName := selAudioFile.name
timedPopup(" Ytdl ", "Downloading", getPopupTimeout(), 0, 0)
// 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
err = cmd.Run()
if err != nil {
timedPopup(" Error ", "Error running youtube-dl", getPopupTimeout(), 0, 0)
return tracerr.Wrap(err)
}
playlistPath := path.Join(expandTilde(dir), selPlaylistName)
audioPath := extractFilePath(stdout.Bytes(), playlistPath)
err = gomu.playlist.addSongToPlaylist(audioPath, selPlaylist)
if err != nil {
return tracerr.Wrap(err)
}
downloadFinishedMessage := fmt.Sprintf("Finished downloading\n%s",
path.Base(audioPath))
timedPopup(
" Ytdl ",
downloadFinishedMessage,
getPopupTimeout(), 0, 0)
gomu.app.Draw()
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,
}
child.SetReference(audioFile)
root.AddChild(child)
}
if file.IsDir() {
audioFile := &AudioFile{
name: songName,
path: path,
node: child,
parent: root,
}
child.SetReference(audioFile)
child.SetColor(gomu.accentColor)
root.AddChild(child)
populate(child, path)
}
}
return nil
}