/* Cover-viewer notifies and display album cover art. This program is designed to be used with cmus. Usage: cover-viewer --mode [visualizer|notify] Flags: --mode [visualizer|notify] Runs cover-viewer in visualizer or notify. In visualizer mode it displays the album cover of the file read from the socket. In notify, reads cmus status line from stdin and sends the file path to the socket. */ package main import ( "bytes" "flag" "fmt" "io" "log" "net" "os" "strings" termimg "github.com/blacktop/go-termimg" "github.com/dhowden/tag" ) // cmusKeys is the set of keys that could appear in the cmus status line. var cmusKeys = map[string]bool{ "status": true, "file": true, "artist": true, "album": true, "albumartist": true, "title": true, "tracknumber": true, "duration": true, } // lastPath indicates the path of the last file read to display the cover, // used to avoid read again the same file again. var lastPath string func main() { log.SetOutput(os.Stderr) log.SetFlags(log.LstdFlags | log.Lshortfile) mode := flag.String("mode", "", "visualizer or notify") flag.Parse() socketPath := getSocketPath() switch *mode { case "visualizer": if err := runVisualizer(socketPath); err != nil { fmt.Fprintln(os.Stderr, "visualize error:", err) os.Exit(1) } case "notify": if err := runNotify(socketPath); err != nil { fmt.Fprintln(os.Stderr, "notify error:", err) os.Exit(1) } default: fmt.Fprintln(os.Stderr, "usage: --mode visualizer|notify") os.Exit(1) } } // getSocketPath gets the socket path func getSocketPath() string { if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" { return xdg + "/music-viewer.sock" } return "/tmp/music-viewer.sock" } // runNotify writes in the socket the content of the Stdin, meant to be the status // string from cmus. func runNotify(socketPath string) error { conn, err := net.Dial("unix", socketPath) if err != nil { log.Println(err) return err } defer conn.Close() _, err = io.Copy(conn, os.Stdin) return err } // runVisualizer listen to the socket and handles the message with the status of // cmus func runVisualizer(socketPath string) error { cleanScreen() _ = os.Remove(socketPath) ln, err := net.Listen("unix", socketPath) if err != nil { log.Println(err) return err } defer ln.Close() for { conn, err := ln.Accept() if err != nil { log.Println(err) continue } handleConn(conn) } } // handleConn handles the socket connection func handleConn(conn net.Conn) { defer conn.Close() // read the message data, err := io.ReadAll(conn) if err != nil { log.Println(err) return } path, ok := extractFilePath(string(data)) if !ok { return } if path == lastPath { return } showCover(path) lastPath = path } // parseCmusLine reads the status line from cmus and parse it to extract the path to // the file func parseCmusLine(input string) map[string]string { parts := strings.Fields(input) result := make(map[string]string) var currentKey string var buffer []string flush := func() { if currentKey != "" && len(buffer) > 0 { result[currentKey] = strings.Join(buffer, " ") } buffer = nil } for _, part := range parts { if cmusKeys[part] { flush() currentKey = part continue } buffer = append(buffer, part) } flush() return result } // extractFilePath extract the file path from the status string func extractFilePath(input string) (string, bool) { data := parseCmusLine(input) path, ok := data["file"] return path, ok } // cleanScreen clean the screen func cleanScreen() { fmt.Print("\033[2J") fmt.Print("\033[H") fmt.Print("\n") } // showCover read the picture tag from the file and shows it in kitty terminal func showCover(path string) { f, err := os.Open(path) if err != nil { log.Println(err) return } defer f.Close() meta, err := tag.ReadFrom(f) if err != nil { log.Println(err) return } pic := meta.Picture() if pic == nil { log.Println(err) return } renderCover(pic.Data) } // renderCover renders the image in the terminal func renderCover(data []byte) { // clean screen cleanScreen() // get terminal size features := termimg.QueryTerminalFeatures() imgData := bytes.NewReader(data) img, err := termimg.From(imgData) if err != nil { log.Println(err) return } img. Width(features.WindowCols). Height(features.WindowRows). Scale(termimg.ScaleFit). Print() }