An application to display the albumn cover of the track played in cmus.
1/*
2Cover-viewer notifies and display album cover art.
3This program is designed to be used with cmus.
4
5Usage:
6
7 cover-viewer --mode [visualizer|notify]
8
9Flags:
10
11 --mode [visualizer|notify]
12 Runs cover-viewer in visualizer or notify. In visualizer mode it displays the
13 album cover of the file read from the socket. In notify, reads cmus status line
14 from stdin and sends the file path to the socket.
15*/
16package main
17
18import (
19 "bytes"
20 "flag"
21 "fmt"
22 "io"
23 "log"
24 "net"
25 "os"
26 "strings"
27
28 termimg "github.com/blacktop/go-termimg"
29 "github.com/dhowden/tag"
30)
31
32// cmusKeys is the set of keys that could appear in the cmus status line.
33var cmusKeys = map[string]bool{
34 "status": true,
35 "file": true,
36 "artist": true,
37 "album": true,
38 "albumartist": true,
39 "title": true,
40 "tracknumber": true,
41 "duration": true,
42}
43
44// lastPath indicates the path of the last file read to display the cover,
45// used to avoid read again the same file again.
46var lastPath string
47
48func main() {
49 log.SetOutput(os.Stderr)
50 log.SetFlags(log.LstdFlags | log.Lshortfile)
51
52 mode := flag.String("mode", "", "visualizer or notify")
53 flag.Parse()
54
55 socketPath := getSocketPath()
56
57 switch *mode {
58 case "visualizer":
59 if err := runVisualizer(socketPath); err != nil {
60 fmt.Fprintln(os.Stderr, "visualize error:", err)
61 os.Exit(1)
62 }
63 case "notify":
64 if err := runNotify(socketPath); err != nil {
65 fmt.Fprintln(os.Stderr, "notify error:", err)
66 os.Exit(1)
67 }
68 default:
69 fmt.Fprintln(os.Stderr, "usage: --mode visualizer|notify")
70 os.Exit(1)
71 }
72}
73
74// getSocketPath gets the socket path
75func getSocketPath() string {
76 if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
77 return xdg + "/music-viewer.sock"
78 }
79 return "/tmp/music-viewer.sock"
80}
81
82// runNotify writes in the socket the content of the Stdin, meant to be the status
83// string from cmus.
84func runNotify(socketPath string) error {
85 conn, err := net.Dial("unix", socketPath)
86 if err != nil {
87 log.Println(err)
88 return err
89
90 }
91 defer conn.Close()
92
93 _, err = io.Copy(conn, os.Stdin)
94 return err
95}
96
97// runVisualizer listen to the socket and handles the message with the status of
98// cmus
99func runVisualizer(socketPath string) error {
100 cleanScreen()
101 _ = os.Remove(socketPath)
102
103 ln, err := net.Listen("unix", socketPath)
104 if err != nil {
105 log.Println(err)
106 return err
107 }
108 defer ln.Close()
109
110 for {
111 conn, err := ln.Accept()
112 if err != nil {
113 log.Println(err)
114 continue
115 }
116 handleConn(conn)
117 }
118}
119
120// handleConn handles the socket connection
121func handleConn(conn net.Conn) {
122 defer conn.Close()
123
124 // read the message
125 data, err := io.ReadAll(conn)
126 if err != nil {
127 log.Println(err)
128 return
129 }
130
131 path, ok := extractFilePath(string(data))
132 if !ok {
133 return
134 }
135
136 if path == lastPath {
137 return
138 }
139
140 showCover(path)
141 lastPath = path
142}
143
144// parseCmusLine reads the status line from cmus and parse it to extract the path to
145// the file
146func parseCmusLine(input string) map[string]string {
147 parts := strings.Fields(input)
148 result := make(map[string]string)
149
150 var currentKey string
151 var buffer []string
152
153 flush := func() {
154 if currentKey != "" && len(buffer) > 0 {
155 result[currentKey] = strings.Join(buffer, " ")
156 }
157 buffer = nil
158 }
159
160 for _, part := range parts {
161 if cmusKeys[part] {
162 flush()
163 currentKey = part
164 continue
165 }
166 buffer = append(buffer, part)
167 }
168
169 flush()
170 return result
171}
172
173// extractFilePath extract the file path from the status string
174func extractFilePath(input string) (string, bool) {
175 data := parseCmusLine(input)
176 path, ok := data["file"]
177 return path, ok
178}
179
180// cleanScreen clean the screen
181func cleanScreen() {
182 fmt.Print("\033[2J")
183 fmt.Print("\033[H")
184 fmt.Print("\n")
185}
186
187// showCover read the picture tag from the file and shows it in kitty terminal
188func showCover(path string) {
189 f, err := os.Open(path)
190 if err != nil {
191 log.Println(err)
192 return
193 }
194 defer f.Close()
195
196 meta, err := tag.ReadFrom(f)
197 if err != nil {
198 log.Println(err)
199 return
200 }
201
202 pic := meta.Picture()
203 if pic == nil {
204 log.Println(err)
205 return
206 }
207
208 renderCover(pic.Data)
209}
210
211// renderCover renders the image in the terminal
212func renderCover(data []byte) {
213 // clean screen
214 cleanScreen()
215
216 // get terminal size
217 features := termimg.QueryTerminalFeatures()
218
219 imgData := bytes.NewReader(data)
220 img, err := termimg.From(imgData)
221 if err != nil {
222 log.Println(err)
223 return
224 }
225
226 img.
227 Width(features.WindowCols).
228 Height(features.WindowRows).
229 Scale(termimg.ScaleFit).
230 Print()
231}