Opening Genius for the Current Spotify Track

EN RU
30.10.2025
windowsgolinuxdbus

Автоматическое открытие Genius для текущего трека Spotify

Всем привет! Я большой фанат музыки и люблю не только слушать треки, но и понимать их смысл. Часто, во время прослушивания, открываю Genius, чтобы почитать тексты и аннотации. Однако делать это вручную — особенно если слушаешь альбом или подборку новинок — быстро надоедает.

Поэтому я решил написать небольшую утилиту, которая автоматически открывает страницу Genius, когда в Spotify начинается новый трек.

Основные функции

  • Определение текущего трека и отслеживание его изменения
  • Поиск страницы трека на Genius
  • Автоматическое открытие страницы в браузере

Я выбрал язык Go просто потому, что он мне нравится. Теперь расскажу, как устроена реализация.


Получение текущего трека

Windows

На Windows я не нашёл простого способа получить название текущего трека. Поэтому пошёл через WinAPI: Spotify меняет заголовок окна при смене трека — именно его мы и будем парсить.

Для этого используется следующий пайплайн:

  1. Создаём снапшот процессов и ищем Spotify.exe
  2. Получаем PID процесса
  3. Используем callback-функцию для поиска окна по PID
  4. Получаем заголовок окна (title)

Пример испорта нужных функций из Windows API в 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)
)

Пример функции, которая возвращает хэндл окна Spotify:

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
}

Окончательная функция для получения заголовка трека:

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
}

Как вы могли заметить функции которые я привожу это методы структуры WindowsSystemController, это структура создана, чтобы отделить функционал зависящий от OS в отдельный файл.

Важно: код для Windows оборачивается в файл с билд-тегом //go:build windows, чтобы он включался только при сборке под Windows.

Linux

Хорошо, мы научились определять текущий трек на Windows, но что делать с другими ОС. На Linux всё намного проще: Spotify поддерживает DBus, и через него можно напрямую получить информацию о текущем треке.

Буду использовать библиотеку: godbus/dbus

Нам нужно установить соединение с DBus шиной:

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
}

Дальше мы пишем функцию получения текущего трека:

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 позволяет обращаться к объекту Spotify и извлекать xesam:artist и xesam:title, из которых мы собираем строку вида "Artist – Track".

Важно: код для Linux оборачивается в файл с билд-тегом //go:build linux, чтобы он включался только при сборке под Linux.


Поиск страницы на Genius

На самом деле тут я перепробовал много способов, я пробовал парсить сайт получая HTML через HTTP запрос, пробовал использовать официальный API Genius, но не 1 из этих методов не удовлетворил всех моих потребностей, что-то просто не работало, а что-то было неудобно для конечного пользователя.

В итоге я остановился на библиотеке rod, с помощью которой, я могу запустить целый инстанс браузера, который будет эмулировать реального пользователя, что поможет нам обойти защиту от парсинга через обычный HTTP запрос. В целом функция по поиску трека достаточно понятная, мы создаём инстанс браузера, переходим на нужную нам страницу, ждём пока она загрузится и из HTML достаём что нам нужно.

Пример функции:

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
}

Если вдруг у нас не удалось найти чёткого совпадения, то мы возвращаем сгенерированый URL поиска, и пользователю останется лишь выбрать трек, если он существует.


Открытие страницы в браузере

После получения ссылки на страницу трека, либо на страницу поиска, нам нужно эту ссылку открыть, и тут опять мы возвращаемся к структурам WindowsSystemController и LinuxSystemController.

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()
}

Финальный пайплайн

Теперь, когда мы написали все основные функции, мы собираем всё вместе:

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)
	}
}

Функция сравнивает текущий и предыдущий трек. Если обнаружена смена — ищет страницу на Genius и открывает её.


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)
}

Функция getSystemController() подбирает реализацию под текущую ОС (благодаря build-тегам).


Taskfile

В Taskfile.yml описаны команды:

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}}

Проверка работы

  1. Сборка: task build
  2. Запуск: task run
  3. Включаем трек — и автоматически открывается страница с текстом на Genius. Вуаля!

Бонус: уменьшение размера бинарника

В команде сборки я использовал флаги:

-ldflags="-s -w" -gcflags=all="-l -B"

Они уменьшают размер итогового файла:

  • -ldflags="-s": удаляет таблицу символов
  • -ldflags="-w": Удаляет отладочную информацию DWARF
  • -gcflags=all="-l": Отключает инлайнинг
  • -gcflags=all="-B": Отключает проверку границ массивов

Результат:

  • Без флагов: 13.8 МБ
  • С флагами: 9.43 МБ
    Экономия — около 31%, что весьма приятно для CLI-утилиты.

Данная тема заслуживает отдельной статьи, так что тут мы не будет особо вдаваться в подробности, советую ознакомиться с данным источником: reduce size matter


Заключение

Вот и всё! Спасибо, если дочитал до конца. Исходный код — на GitHub:
👉 spotify-auto-genius