Automatically Opening Genius for the Current Spotify Track
Hello everyone! I'm a big music fan and I love not only listening to tracks but also understanding their meaning. Often, while listening, I open Genius to read lyrics and annotations. However, doing this manually — especially when listening to an album or a new releases playlist — quickly becomes tedious.
That's why I decided to write a small utility that automatically opens the Genius page when a new track starts playing in Spotify.
Main Features
- Detecting the current track and tracking its changes
- Finding the track page on Genius
- Automatically opening the page in a browser
I chose Go simply because I like it. Now, let me explain how the implementation works.
Getting the Current Track
Windows
On Windows, I couldn't find a simple way to get the current track name. Therefore, I went through WinAPI: Spotify changes the window title when a track changes — that's what we'll parse.
Here's the pipeline used:
- Create a process snapshot and search for
Spotify.exe - Get the process PID
- Use a callback function to find the window by PID
- Get the window title
Example of importing required functions from Windows API in Go:
var (
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
modUser32 = windows.NewLazySystemDLL("user32.dll")
procCreateToolhelp32Snapshot = modKernel32.NewProc("CreateToolhelp32Snapshot")
procProcess32First = modKernel32.NewProc("Process32FirstW")
procProcess32Next = modKernel32.NewProc("Process32NextW")
procEnumWindows = modUser32.NewProc("EnumWindows")
procGetWindowThreadProcessId = modUser32.NewProc("GetWindowThreadProcessId")
procIsWindowVisible = modUser32.NewProc("IsWindowVisible")
procGetWindowTextLengthW = modUser32.NewProc("GetWindowTextLengthW")
procGetWindowTextW = modUser32.NewProc("GetWindowTextW")
cb = syscall.NewCallback(enumWindowsCallback)
)
Example function that returns the Spotify window handle:
func (w *WindowsSystemController) getSpotifyWindow() (windows.HWND, error) {
snapshot, _, _ := procCreateToolhelp32Snapshot.Call(TH32CS_SNAPPROCESS, 0)
if snapshot == 0 || snapshot == uintptr(windows.InvalidHandle) {
return 0, errors.New("failed to create snapshot")
}
defer syscall.CloseHandle(syscall.Handle(snapshot))
var entry ProcessEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
r1, _, _ := procProcess32First.Call(snapshot, uintptr(unsafe.Pointer(&entry)))
if r1 == 0 {
return 0, errors.New("failed to get first process")
}
var spotifyPID uint32
for {
exe := syscall.UTF16ToString(entry.ExeFile[:])
if strings.EqualFold(exe, "Spotify.exe") {
spotifyPID = entry.ProcessID
break
}
r, _, _ := procProcess32Next.Call(snapshot, uintptr(unsafe.Pointer(&entry)))
if r == 0 {
break
}
}
if spotifyPID == 0 {
return 0, errors.New("failed to find Spotify process")
}
searchData := WindowSearchData{
PID: spotifyPID,
Window: 0,
}
procEnumWindows.Call(cb, uintptr(unsafe.Pointer(&searchData)))
return searchData.Window, nil
}
Final function to get the track title:
func (w *WindowsSystemController) GetCurrentPlayingTrackTitle() (string, error) {
hwnd, err := w.getSpotifyWindow()
if hwnd == 0 {
return "", err
}
length, _, _ := procGetWindowTextLengthW.Call(uintptr(hwnd))
if length == 0 {
return "", errors.New("failed to get window text length")
}
buf := make([]uint16, length+1)
procGetWindowTextW.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&buf[0])), length+1)
windowTitle := syscall.UTF16ToString(buf)
if windowTitle == "Spotify Premium" || windowTitle == "Spotify" {
return "", errors.New("Spotify is running but not playing a track")
}
return windowTitle, nil
}
As you might have noticed, these functions are methods of the WindowsSystemController struct, which was created to separate OS-dependent functionality into a separate file.
Important: The Windows code is wrapped in a file with the build tag //go:build windows so it's only included when building for Windows.
Linux
Well, we've learned how to detect the current track on Windows, but what about other OSes? On Linux, it's much simpler: Spotify supports DBus, and through it, we can directly get information about the current track.
I'll use the library: godbus/dbus
We need to establish a connection with the DBus bus:
type LinuxSystemController struct {
conn *dbus.Conn
}
func NewSystemController() (*LinuxSystemController, error) {
conn, err := dbus.ConnectSessionBus()
if err != nil {
return nil, err
}
return &LinuxSystemController{
conn: conn,
}, nil
}
Next, we write the function to get the current track:
func (sc *LinuxSystemController) GetCurrentPlayingTrackTitle() (string, error) {
var metaDataReply map[string]dbus.Variant
obj := sc.conn.Object("org.mpris.MediaPlayer2.spotify", "/org/mpris/MediaPlayer2")
err := obj.Call("org.freedesktop.DBus.Properties.Get", 0, "org.mpris.MediaPlayer2.Player", "Metadata").Store(&metaDataReply)
if err != nil {
return "", err
}
artists := metaDataReply["xesam:artist"].Value().([]string)
title := metaDataReply["xesam:title"].Value().(string)
if title == "" {
return "", errors.New("Spotify is running but not playing a track")
}
return fmt.Sprintf(
"%s - %s",
strings.Join(artists, ","),
title,
), nil
}
DBus allows us to access the Spotify object and extract xesam:artist and xesam:title, from which we compose a string in the format "Artist – Track".
Important: The Linux code is wrapped in a file with the build tag //go:build linux so it's only included when building for Linux.
Finding the Page on Genius
Actually, I tried many approaches here. I tried parsing the site by getting HTML through HTTP requests, tried using the official Genius API, but none of these methods satisfied all my needs — some simply didn't work, while others were inconvenient for the end user.
In the end, I settled on the rod library, with which I can launch a full browser instance that will emulate a real user, helping us bypass anti-scraping protection through regular HTTP requests. Overall, the track search function is quite straightforward: we create a browser instance, navigate to the page we need, wait for it to load, and extract what we need from the HTML.
Example function:
func (g *Genius) GetTrackPageURL(title string) (string, error) {
url := "https://genius.com/search?q=" + title
browser := rod.New().Timeout(10 * time.Second)
if err := browser.Connect(); err != nil {
return "", errors.New("failed to connect to browser: " + err.Error())
}
defer browser.Close()
page, err := browser.Page(proto.TargetCreateTarget{URL: url})
if err != nil {
return "", errors.New("failed to create page: " + err.Error())
}
if err := page.WaitLoad(); err != nil {
return "", errors.New("failed to load page: " + err.Error())
}
html, err := page.HTML()
if err != nil {
return "", errors.New("failed to get HTML: " + err.Error())
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
log.Printf("Failed to parse HTML: %v", err)
log.Println("Opening search page", url)
return url, nil
}
href, exists := doc.Find("a.mini_card").First().Attr("href")
if !exists {
log.Println("No exact match found, opening search page:", url)
return url, nil
}
log.Println("Found exact match:", href)
return href, nil
}
If we couldn't find an exact match, we return the generated search URL, and the user will only need to select the track if it exists.
Opening the Page in a Browser
After getting the link to the track page or search page, we need to open this link, and here we return to the WindowsSystemController and LinuxSystemController structures.
Linux
func (sc *LinuxSystemController) OpenURLInBrowser(url string) error {
return exec.Command("open", url).Start()
}
Windows
func (w *WindowsSystemController) OpenURLInBrowser(url string) error {
cmd := exec.Command("cmd", "/C", "start", "", url)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Run()
}
Final Pipeline
Now that we've written all the main functions, we put everything together:
func (s *Spotify) Run(scanInterval time.Duration) {
log.Println("Starting scanning Spotify...")
for {
title, err := s.systemController.GetCurrentPlayingTrackTitle()
if err != nil {
log.Println("Spotify is not running...Waiting 5 seconds...")
time.Sleep(5 * time.Second)
continue
}
if title != s.prevTitle {
log.Println("New track:", title)
s.prevTitle = title
url, err := s.genius.GetTrackPageURL(title)
if err != nil {
log.Printf("Failed to open Genius page: %v", err)
}
if err := s.systemController.OpenURLInBrowser(url); err != nil {
log.Printf("Failed to open Genius page: %v", err)
}
}
time.Sleep(scanInterval)
}
}
The function compares the current and previous track. If a change is detected — it searches for the page on Genius and opens it.
main.go
package main
import (
"log"
"time"
"github.com/MowlCoder/spotify-auto-genius/internal/genius"
"github.com/MowlCoder/spotify-auto-genius/internal/spotify"
"github.com/MowlCoder/spotify-auto-genius/internal/system"
)
func getSystemController() (spotify.SystemController, error) {
return system.NewSystemController()
}
func main() {
systemController, err := getSystemController()
if err != nil {
log.Fatalf("Failed to get system controller: %v", err)
}
genius := genius.NewGenius()
spotifyWorker := spotify.NewSpotify(systemController, genius)
spotifyWorker.Run(1 * time.Second)
}
The getSystemController() function selects the implementation for the current OS (thanks to build tags).
Taskfile
The Taskfile.yml describes the commands:
version: "3"
vars:
EXT: '{{if eq OS "windows"}}.exe{{end}}'
tasks:
dev:
cmds:
- go run cmd/spotify-auto-genius/main.go
build:
cmds:
- go build -o bin/spotify-auto-genius{{.EXT}} -ldflags="-s -w" -gcflags=all="-l -B" ./cmd/spotify-auto-genius/main.go
run:
cmds:
- ./bin/spotify-auto-genius{{.EXT}}
clean:
cmds:
- |
{{if eq OS "windows"}}
del bin\spotify-auto-genius{{.EXT}}
{{else}}
rm -f bin/spotify-auto-genius{{.EXT}}
{{end}}
Testing the Work
- Build:
task build - Run:
task run - Play a track — and the page with lyrics automatically opens on Genius. Voilà!
Bonus: Reducing Binary Size
In the build command, I used flags:
-ldflags="-s -w" -gcflags=all="-l -B"
They reduce the size of the final file:
-ldflags="-s": removes the symbol table-ldflags="-w": Removes DWARF debug information-gcflags=all="-l": Disables inlining-gcflags=all="-B": Disables array bounds checking
Result:
- Without flags: 13.8 MB
- With flags: 9.43 MB
Savings — about 31%, which is quite nice for a CLI utility.
This topic deserves a separate article, so we won't go into details here. I recommend checking out this source: reduce size matter
Conclusion
That's it! Thanks if you've read this far. Source code is on GitHub:
👉 spotify-auto-genius