Автоматическое открытие Genius для текущего трека Spotify
Всем привет! Я большой фанат музыки и люблю не только слушать треки, но и понимать их смысл. Часто, во время прослушивания, открываю Genius, чтобы почитать тексты и аннотации. Однако делать это вручную — особенно если слушаешь альбом или подборку новинок — быстро надоедает.
Поэтому я решил написать небольшую утилиту, которая автоматически открывает страницу Genius, когда в Spotify начинается новый трек.
Основные функции
- Определение текущего трека и отслеживание его изменения
- Поиск страницы трека на Genius
- Автоматическое открытие страницы в браузере
Я выбрал язык Go просто потому, что он мне нравится. Теперь расскажу, как устроена реализация.
Получение текущего трека
Windows
На Windows я не нашёл простого способа получить название текущего трека. Поэтому пошёл через WinAPI: Spotify меняет заголовок окна при смене трека — именно его мы и будем парсить.
Для этого используется следующий пайплайн:
- Создаём снапшот процессов и ищем
Spotify.exe - Получаем PID процесса
- Используем callback-функцию для поиска окна по PID
- Получаем заголовок окна (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}}
Проверка работы
- Сборка:
task build - Запуск:
task run - Включаем трек — и автоматически открывается страница с текстом на 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