Add support yandex source

This commit is contained in:
Alex X
2025-06-12 16:52:05 +03:00
parent ae8145f266
commit a4d7fd0d95
6 changed files with 425 additions and 1 deletions

22
internal/yandex/README.md Normal file
View File

@@ -0,0 +1,22 @@
# Yandex
Source for receiving stream from new [Yandex IP camera](https://alice.yandex.ru/smart-home/security/ipcamera).
## Get Yandex token
1. Install HomeAssistant integration [YandexStation](https://github.com/AlexxIT/YandexStation).
2. Copy token from HomeAssistant config folder: `/config/.storage/core.config_entries`, key: `"x_token"`.
## Get device ID
1. Open this link in any browser: https://iot.quasar.yandex.ru/m/v3/user/devices
2. Copy ID of your camera, key: `"id"`.
## Config examples
```yaml
streams:
yandex_stream: yandex:?x_token=XXXX&device_id=XXXX
yandex_snapshot: yandex:?x_token=XXXX&device_id=XXXX&snapshot
yandex_snapshot_custom_size: yandex:?x_token=XXXX&device_id=XXXX&snapshot=h=540
```

152
internal/yandex/goloom.go Normal file
View File

@@ -0,0 +1,152 @@
package yandex
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/AlexxIT/go2rtc/internal/webrtc"
"github.com/AlexxIT/go2rtc/pkg/core"
xwebrtc "github.com/AlexxIT/go2rtc/pkg/webrtc"
"github.com/google/uuid"
"github.com/gorilla/websocket"
pion "github.com/pion/webrtc/v4"
)
func goloomClient(serviceURL, serviceName, roomId, participantId, credentials string) (core.Producer, error) {
conn, _, err := websocket.DefaultDialer.Dial(serviceURL, nil)
if err != nil {
return nil, err
}
defer func() {
time.Sleep(time.Second)
_ = conn.Close()
}()
s := fmt.Sprintf(`{"hello": {
"credentials":"%s","participantId":"%s","roomId":"%s","serviceName":"%s","sdkInitializationId":"%s",
"capabilitiesOffer":{},"sendAudio":false,"sendSharing":false,"sendVideo":false,
"sdkInfo":{"hwConcurrency":4,"implementation":"browser","version":"5.4.0"},
"participantAttributes":{"description":"","name":"mike","role":"SPEAKER"},
"participantMeta":{"description":"","name":"mike","role":"SPEAKER","sendAudio":false,"sendVideo":false}
},"uid":"%s"}`,
credentials, participantId, roomId, serviceName,
uuid.NewString(), uuid.NewString(),
)
err = conn.WriteMessage(websocket.TextMessage, []byte(s))
if err != nil {
return nil, err
}
if _, _, err = conn.ReadMessage(); err != nil {
return nil, err
}
pc, err := webrtc.PeerConnection(true)
if err != nil {
return nil, err
}
prod := xwebrtc.NewConn(pc)
prod.FormatName = "yandex"
prod.Mode = core.ModeActiveProducer
prod.Protocol = "wss"
var connState core.Waiter
prod.Listen(func(msg any) {
switch msg := msg.(type) {
case pion.PeerConnectionState:
switch msg {
case pion.PeerConnectionStateConnecting:
case pion.PeerConnectionStateConnected:
connState.Done(nil)
default:
connState.Done(errors.New("webrtc: " + msg.String()))
}
}
})
go func() {
for {
var msg map[string]json.RawMessage
if err = conn.ReadJSON(&msg); err != nil {
return
}
for k, v := range msg {
switch k {
case "uid":
continue
case "serverHello":
case "subscriberSdpOffer":
var sdp subscriberSdp
if err = json.Unmarshal(v, &sdp); err != nil {
return
}
//log.Trace().Msgf("offer:\n%s", sdp.Sdp)
if err = prod.SetOffer(sdp.Sdp); err != nil {
return
}
if sdp.Sdp, err = prod.GetAnswer(); err != nil {
return
}
//log.Trace().Msgf("answer:\n%s", sdp.Sdp)
var raw []byte
if raw, err = json.Marshal(sdp); err != nil {
return
}
s = fmt.Sprintf(`{"uid":"%s","subscriberSdpAnswer":%s}`, uuid.NewString(), raw)
if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil {
return
}
case "webrtcIceCandidate":
var candidate webrtcIceCandidate
if err = json.Unmarshal(v, &candidate); err != nil {
return
}
if err = prod.AddCandidate(candidate.Candidate); err != nil {
return
}
}
//log.Trace().Msgf("%s : %s", k, v)
}
if msg["ack"] != nil {
continue
}
s = fmt.Sprintf(`{"uid":%s,"ack":{"status":{"code":"OK"}}}`, msg["uid"])
if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil {
return
}
}
}()
if err = connState.Wait(); err != nil {
return nil, err
}
s = fmt.Sprintf(`{"uid":"%s","setSlots":{"slots":[{"width":0,"height":0}],"audioSlotsCount":0,"key":1,"shutdownAllVideo":false,"withSelfView":false,"selfViewVisibility":"ON_LOADING_THEN_HIDE","gridConfig":{}}}`, uuid.NewString())
if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil {
return nil, err
}
return prod, nil
}
type subscriberSdp struct {
PcSeq int `json:"pcSeq"`
Sdp string `json:"sdp"`
}
type webrtcIceCandidate struct {
PcSeq int `json:"pcSeq"`
Target string `json:"target"`
Candidate string `json:"candidate"`
SdpMid string `json:"sdpMid"`
SdpMlineIndex int `json:"sdpMlineIndex"`
}

44
internal/yandex/yandex.go Normal file
View File

@@ -0,0 +1,44 @@
package yandex
import (
"net/url"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/yandex"
)
func Init() {
streams.HandleFunc("yandex", func(source string) (core.Producer, error) {
u, err := url.Parse(source)
if err != nil {
return nil, err
}
query := u.Query()
token := query.Get("x_token")
session, err := yandex.GetSession(token)
if err != nil {
return nil, err
}
deviceID := query.Get("device_id")
if query.Has("snapshot") {
rawURL, err := session.GetSnapshotURL(deviceID)
if err != nil {
return nil, err
}
rawURL += "/current.jpg?" + query.Get("snapshot") + "#header=Cookie:" + session.GetCookieString(rawURL)
return streams.GetProducer(rawURL)
}
room, err := session.WebrtcCreateRoom(deviceID)
if err != nil {
return nil, err
}
return goloomClient(room.ServiceUrl, room.ServiceName, room.RoomId, room.ParticipantId, room.Credentials)
})
}

View File

@@ -39,6 +39,7 @@ import (
"github.com/AlexxIT/go2rtc/internal/webrtc"
"github.com/AlexxIT/go2rtc/internal/webtorrent"
"github.com/AlexxIT/go2rtc/internal/wyoming"
"github.com/AlexxIT/go2rtc/internal/yandex"
"github.com/AlexxIT/go2rtc/pkg/shell"
)
@@ -96,6 +97,7 @@ func main() {
alsa.Init() // alsa source
flussonic.Init()
eseecloud.Init()
yandex.Init()
// 6. Helper modules

View File

@@ -65,7 +65,8 @@ transeivers:
switch tr.Direction() {
case webrtc.RTPTransceiverDirectionSendrecv:
_ = tr.Sender().Stop()
_ = tr.Sender().Stop() // don't know if necessary
_ = tr.SetSender(tr.Sender(), nil) // set direction to recvonly
case webrtc.RTPTransceiverDirectionSendonly:
_ = tr.Stop()
}

203
pkg/yandex/session.go Normal file
View File

@@ -0,0 +1,203 @@
package yandex
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"sync"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
)
type Session struct {
token string
client *http.Client
}
var sessions = map[string]*Session{}
var sessionsMu sync.Mutex
func GetSession(token string) (*Session, error) {
sessionsMu.Lock()
defer sessionsMu.Unlock()
if session, ok := sessions[token]; ok {
return session, nil
}
session := &Session{token: token}
if err := session.Login(); err != nil {
return nil, err
}
sessions[token] = session
return session, nil
}
func (s *Session) Login() error {
req, err := http.NewRequest(
"POST", "https://mobileproxy.passport.yandex.net/1/bundle/auth/x_token/",
strings.NewReader("type=x-token&retpath=https%3A%2F%2Fwww.yandex.ru"),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Ya-Consumer-Authorization", "OAuth "+s.token)
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
var auth struct {
PassportHost string `json:"passport_host"`
Status string `json:"status"`
TrackId string `json:"track_id"`
}
if err = json.NewDecoder(res.Body).Decode(&auth); err != nil {
return err
}
if auth.Status != "ok" {
return errors.New("yandex: login error: " + auth.Status)
}
s.client = &http.Client{Timeout: 15 * time.Second}
s.client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
s.client.Jar, _ = cookiejar.New(nil)
res, err = s.client.Get(auth.PassportHost + "/auth/session/?track_id=" + auth.TrackId)
if err != nil {
return err
}
s.client.CheckRedirect = nil
return nil
}
func (s *Session) Get(url string) (*http.Response, error) {
return s.client.Get(url)
}
func (s *Session) GetCSRF() (string, error) {
res, err := s.Get("https://yandex.ru/quasar")
if err != nil {
return "", err
}
body, err := io.ReadAll(res.Body)
if err != nil {
return "", err
}
token := core.Between(string(body), `"csrfToken2":"`, `"`)
return token, nil
}
func (s *Session) GetCookieString(url string) string {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return ""
}
for _, cookie := range s.client.Jar.Cookies(req.URL) {
req.AddCookie(cookie)
}
return req.Header.Get("Cookie")
}
func (s *Session) GetDevices() ([]Device, error) {
res, err := s.Get("https://iot.quasar.yandex.ru/m/v3/user/devices")
if err != nil {
return nil, err
}
var data struct {
Households []struct {
All []Device `json:"all"`
} `json:"households"`
}
if err = json.NewDecoder(res.Body).Decode(&data); err != nil {
return nil, err
}
var devices []Device
for _, household := range data.Households {
devices = append(devices, household.All...)
}
return devices, nil
}
func (s *Session) GetSnapshotURL(deviceID string) (string, error) {
devices, err := s.GetDevices()
if err != nil {
return "", err
}
for _, device := range devices {
if device.Id == deviceID {
return device.Parameters.SnapshotUrl, nil
}
}
return "", errors.New("yandex: can't get snapshot url for device: " + deviceID)
}
func (s *Session) WebrtcCreateRoom(deviceID string) (*Room, error) {
csrf, err := s.GetCSRF()
if err != nil {
return nil, err
}
req, err := http.NewRequest(
"POST", "https://iot.quasar.yandex.ru/m/v3/user/devices/"+deviceID+"/webrtc/create-room",
strings.NewReader(`{"protocol":"whip"}`),
)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("X-CSRF-Token", csrf)
res, err := s.client.Do(req)
if err != nil {
return nil, err
}
var data struct {
Result Room `json:"result"`
}
if err = json.NewDecoder(res.Body).Decode(&data); err != nil {
return nil, err
}
return &data.Result, nil
}
type Device struct {
Id string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Parameters struct {
SnapshotUrl string `json:"snapshot_url,omitempty"`
} `json:"parameters"`
}
type Room struct {
ServiceUrl string `json:"service_url"`
ServiceName string `json:"service_name"`
RoomId string `json:"room_id"`
ParticipantId string `json:"participant_id"`
Credentials string `json:"jwt"`
}