Воспроизведение видео с youtube через Яндекс.Станцию
После приобретения Яндекс.Станции я был немного удивлен - она не умеет нормально воспроизводить видео с youtube. Точнее умеет, но UX по работе с ютубом это просто ужас. Было принято решение написать сервис, который бы забирал ссылку нужного видео через телеграм бота и воспроизводил через станцию.
Структура проекта:
Я решил не использовать библиотеки для работы с telegram, в данном случае это не нужно, нам необходимо только мониторить поступающие сообщения.
telegram.go
package telegram
import (
"encoding/json"
"go.uber.org/zap"
"io"
"net/http"
)
type Updates struct {
Updates []Update `json:"result"`
}
type Update struct {
Update_id int `json:"update_id"`
Message Message `json:"message"`
}
type Message struct {
Message_id int `json:"message_id"`
From From `json:"from"`
Chat Chat `json:"chat"`
Date int `json:"date"`
Text string `json:"text"`
}
type From struct {
Id int `json:"id"`
Is_bot bool `json:"is_bot"`
First_name string `json:"first_name"`
Username string `json:"username"`
Language_code string `json:"language_code"`
}
type Chat struct {
Id int `json:"id"`
First_name string `json:"first_name"`
Username string `json:"username"`
Type string `json:"type"`
}
func GetLastTelegramPrivateMessage(l *zap.SugaredLogger, client *http.Client, botUrl string) (int, string) {
updates := Updates{}
resp, err := client.Get(botUrl + "/getupdates?offset=-1")
if err != nil {
l.Error(err.Error())
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
l.Error(err.Error())
}
}(resp.Body)
body, err := io.ReadAll(resp.Body)
err = json.Unmarshal(body, &updates)
if err != nil {
l.Fatal(err.Error())
}
var updateId int
var data string
if len(updates.Updates) > 0 {
updateId = updates.Updates[0].Update_id
data = updates.Updates[0].Message.Text
}
return updateId, data
}
Покопавшись в апи телеграма, было решено пулить последнее сообщение. Метод возвращает id этого сообщения и само тело.
Для авторизации в Яндексе я использую отправку формы, а не токен приложения. Не хотелось лишний раз заморачиваться, кука живет долго.
ya_token.go
package yandex
import (
"go.uber.org/zap"
"io"
"net/http"
"net/url"
)
func GetToken(l *zap.SugaredLogger, client *http.Client, login string, passwd string) string {
// get cookie (yandexid)
resp, err := client.Get("https://passport.yandex.ru/")
if err != nil {
l.Fatal("Cannot get cookie.", err.Error())
}
// auth with cookie and get SessionId
resp, err = client.PostForm("https://passport.yandex.ru/passport?mode=auth&retpath=https://yandex.ru", url.Values{
"login": {login},
"passwd": {passwd},
})
if err != nil {
l.Fatal("Cannot auth at Yandex.", err.Error())
}
// get csrf token
resp, err = client.Get("https://frontend.vh.yandex.ru/csrf_token")
if err != nil {
l.Fatal("Cannot get csrf token.", err.Error())
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
l.Fatal(err.Error())
}
}(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
l.Fatal(err.Error())
}
if string(body) == "Can't get token" {
l.Fatal("Can't get yandex token: ", resp.StatusCode)
}
return string(body)
}
Далее с помощью csrf токена нам необходимо получить список устройств и определить среди них станцию.
ya_devices.go
package yandex
import (
"encoding/json"
"go.uber.org/zap"
"io"
"net/http"
)
type Devices struct {
Devices []Device `json:"items"`
}
type Device struct {
Icon string `json:"icon"`
Id string `json:"id"`
Name string `json:"name"`
Online bool `json:"online"`
Platform string `json:"platform"`
Screen_capable bool `json:"screen_capable"`
Screen_present bool `json:"screen_present"`
}
var yaDeviceId string
func GetDevices(l *zap.SugaredLogger, client *http.Client) string {
resp, err := client.Get("https://quasar.yandex.ru/devices_online_stats")
if err != nil {
l.Fatal("Cannot get devices.", err.Error())
}
body, err := io.ReadAll(resp.Body)
if err != nil {
l.Fatal(err.Error())
}
devices := Devices{}
err = json.Unmarshal(body, &devices)
if err != nil {
l.Fatal(err.Error())
}
for i := 0; i < len(devices.Devices); i++ {
if devices.Devices[i].Platform == "yandexstation" {
yaDeviceId = devices.Devices[i].Id
}
}
return yaDeviceId
}
И непосредственно отправка ссылки Алисе. Колонка может воспроизводить так же видео из поисковой выдачи, тогда используется плеер ott (TODO).
ya_station.go
package yandex
import (
"bytes"
"encoding/json"
"go.uber.org/zap"
"net/http"
"regexp"
"strings"
)
type Message struct {
Msg Msg `json:"msg"`
Device string `json:"device"`
}
type Msg struct {
Provider_item_id string `json:"provider_item_id"`
Player_id string `json:"player_id"`
}
func validationUrl(l *zap.SugaredLogger, url string, pattern string) bool {
matched, err := regexp.MatchString(pattern, url)
if err != nil {
l.Fatal(err.Error())
}
return matched
}
func SendYoutubeToStation(l *zap.SugaredLogger, message string, client *http.Client, device string, token string) {
var result string
if validationUrl(l, message ,"https://m.*") {
result = strings.ReplaceAll(message, "https://m.", "https://www.")
} else if validationUrl(l, message ,"https://youtu.be/*") {
result = strings.ReplaceAll(message, "https://youtu.be/", "https://www.youtube.com/watch?v=")
} else {
result = message
}
msg := Msg{Provider_item_id: result, Player_id: "youtube"}
body := &Message{
Msg: msg,
Device: device,
}
payloadBuf := new(bytes.Buffer)
err := json.NewEncoder(payloadBuf).Encode(body)
if err != nil {
l.Error(err.Error())
}
req, err := http.NewRequest("POST", "https://yandex.ru/video/station", payloadBuf)
if err != nil {
l.Fatal(err.Error())
}
req.Header.Set("x-csrf-token", token)
resp, err := client.Do(req)
if err != nil {
l.Fatal(err.Error())
}
l.Info("Send video to station: ", resp.StatusCode)
}
В методе main я создаю http клиента для сохранения сессии и реализую пулинг сообщений от бота.
alisazavr.go
package main
import (
"github.com/outoffcontrol/alisazavr/internal/telegram"
"github.com/outoffcontrol/alisazavr/internal/yandex"
"go.uber.org/zap"
"net/http"
"net/http/cookiejar"
"os"
)
func main() {
//init logger https://github.com/uber-go/zap
productionLogger, err := zap.NewProduction()
if err != nil {
panic(err)
}
defer func() { _ = productionLogger.Sync() }()
sugaredLogger := productionLogger.Sugar()
sugaredLogger.Info("Starting!")
//init http client with cookie
jar, err := cookiejar.New(nil)
if err != nil { }
client := &http.Client{
Jar: jar,
}
// credentials for yandex.passport
login := os.Getenv("LOGIN")
if login == "" {
sugaredLogger.Fatal("LOGIN is empty.")
}
passwd := os.Getenv("PASSWD")
if passwd == "" {
sugaredLogger.Fatal("PASSWD is empty.")
}
botToken := os.Getenv("BOT_TOKEN")
if botToken == "" {
sugaredLogger.Fatal("BOT_TOKEN is empty.")
}
botUrl := "https://api.telegram.org/bot" + botToken
update_id_temp, _ := telegram.GetLastTelegramPrivateMessage(sugaredLogger, client, botUrl)
token := yandex.GetToken(sugaredLogger, client, login, passwd)
device := yandex.GetDevices(sugaredLogger, client)
for true {
update_id, message := telegram.GetLastTelegramPrivateMessage(sugaredLogger, client, botUrl)
if update_id != update_id_temp {
sugaredLogger.Info("New message in telegram bot.")
yandex.SendYoutubeToStation(sugaredLogger, message, client, device, token)
update_id_temp = update_id
}
}
}
Проект собирается в docker, при запуске необходимо указать токен бота и логин/пароль аккаунта яндекс. Ознакомиться можно тут github.
Gracias y adiós.