Alisazavr

Posted by Gennady on Tuesday, September 7, 2021

Воспроизведение видео с youtube через Яндекс.Станцию

После приобретения Яндекс.Станции я был немного удивлен - она не умеет нормально воспроизводить видео с youtube. Точнее умеет, но UX по работе с ютубом это просто ужас. Было принято решение написать сервис, который бы забирал ссылку нужного видео через телеграм бота и воспроизводил через станцию.

Структура проекта: screenshot

Я решил не использовать библиотеки для работы с 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.