diff --git a/README.md b/README.md index 030fc757..dfbe79b0 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF * [Source: Hass](#source-hass) * [Source: ISAPI](#source-isapi) * [Source: Nest](#source-nest) + * [Source: Ring](#source-ring) * [Source: Roborock](#source-roborock) * [Source: WebRTC](#source-webrtc) * [Source: WebTorrent](#source-webtorrent) @@ -200,6 +201,7 @@ Available source types: - [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR - [dvrip](#source-dvrip) - streaming from DVR-IP NVR - [tapo](#source-tapo) - TP-Link Tapo cameras with [two way audio](#two-way-audio) support +- [ring](#source-ring) - Ring cameras with [two way audio](#two-way-audio) support - [kasa](#source-tapo) - TP-Link Kasa cameras - [gopro](#source-gopro) - GoPro cameras - [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service @@ -221,6 +223,7 @@ Supported sources: - [Hikvision ISAPI](#source-isapi) cameras - [Roborock vacuums](#source-roborock) models with cameras - [Exec](#source-exec) audio on server +- [Ring](#source-ring) cameras - [Any Browser](#incoming-browser) as IP-camera Two-way audio can be used in browser with [WebRTC](#module-webrtc) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)). @@ -647,6 +650,16 @@ streams: nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=*** ``` +#### Source: Ring + +This source type support Ring cameras with [two way audio](#two-way-audio) support. If you have a `refresh_token` and `device_id` - you can use it in `go2rtc.yaml` config file. Otherwise, you can use the go2rtc interface and add your ring account (WebUI > Add > Ring). Once added, it will list all your Ring cameras. + +```yaml +streams: + ring: ring:?device_id=XXX&refresh_token=XXX + ring_snapshot: ring:?device_id=XXX&refresh_token=XXX&snapshot +``` + #### Source: Roborock *[New in v1.3.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0)* diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go index e3b0c161..242c151d 100644 --- a/internal/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -80,7 +80,7 @@ var defaults = map[string]string{ // `-profile high -level 4.1` - most used streaming profile // `-pix_fmt:v yuv420p` - important for Telegram "h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", - "h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", + "h265": "-c:v libx265 -g 50 -profile:v main -x265-params level=5.1:high-tier=0 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", "mjpeg": "-c:v mjpeg", //"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p", diff --git a/internal/ring/ring.go b/internal/ring/ring.go index 673ea480..7fdb284f 100644 --- a/internal/ring/ring.go +++ b/internal/ring/ring.go @@ -1,10 +1,11 @@ package ring import ( - "encoding/json" "net/http" "net/url" + "fmt" + "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" @@ -21,8 +22,7 @@ func Init() { func apiRing(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() - var ringAPI *ring.RingRestClient - var err error + var ringAPI *ring.RingApi // Check auth method if email := query.Get("email"); email != "" { @@ -30,7 +30,8 @@ func apiRing(w http.ResponseWriter, r *http.Request) { password := query.Get("password") code := query.Get("code") - ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{ + var err error + ringAPI, err = ring.NewRestClient(ring.EmailAuth{ Email: email, Password: password, }, nil) @@ -44,7 +45,7 @@ func apiRing(w http.ResponseWriter, r *http.Request) { if _, err = ringAPI.GetAuth(code); err != nil { if ringAPI.Using2FA { // Return 2FA prompt - json.NewEncoder(w).Encode(map[string]interface{}{ + api.ResponseJSON(w, map[string]interface{}{ "needs_2fa": true, "prompt": ringAPI.PromptFor2FA, }) @@ -53,36 +54,39 @@ func apiRing(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - } else { + } else if refreshToken := query.Get("refresh_token"); refreshToken != "" { // Refresh Token Flow - refreshToken := query.Get("refresh_token") if refreshToken == "" { http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest) return } - ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{ + var err error + ringAPI, err = ring.NewRestClient(ring.RefreshTokenAuth{ RefreshToken: refreshToken, }, nil) + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + } else { + http.Error(w, "either email/password or refresh token is required", http.StatusBadRequest) + return } - // Fetch devices devices, err := ringAPI.FetchRingDevices() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - // Create clean query with only required parameters cleanQuery := url.Values{} cleanQuery.Set("refresh_token", ringAPI.RefreshToken) var items []*api.Source for _, camera := range devices.AllCameras { + cleanQuery.Set("camera_id", fmt.Sprint(camera.ID)) cleanQuery.Set("device_id", camera.DeviceID) // Stream source diff --git a/pkg/h265/rtp.go b/pkg/h265/rtp.go index 7a55b408..72d2c02f 100644 --- a/pkg/h265/rtp.go +++ b/pkg/h265/rtp.go @@ -9,8 +9,8 @@ import ( ) func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { - //vps, sps, pps := GetParameterSet(codec.FmtpLine) - //ps := h264.EncodeAVC(vps, sps, pps) + vps, sps, pps := GetParameterSet(codec.FmtpLine) + ps := h264.JoinNALU(vps, sps, pps) buf := make([]byte, 0, 512*1024) // 512K var nuStart int @@ -40,9 +40,9 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { nuType = data[2] & 0x3F // push PS data before keyframe - //if len(buf) == 0 && nuType >= 19 && nuType <= 21 { - // buf = append(buf, ps...) - //} + if len(buf) == 0 && nuType >= 19 && nuType <= 21 { + buf = append(buf, ps...) + } nuStart = len(buf) buf = append(buf, 0, 0, 0, 0) // NAL unit size diff --git a/pkg/ring/api.go b/pkg/ring/api.go index ed69465f..ea7c95ad 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -11,9 +11,13 @@ import ( "net/http" "reflect" "strings" + "sync" "time" ) +var clientCache = map[string]*RingApi{} +var cacheMutex sync.Mutex + type RefreshTokenAuth struct { RefreshToken string } @@ -23,13 +27,11 @@ type EmailAuth struct { Password string } -// AuthConfig represents the decoded refresh token data type AuthConfig struct { RT string `json:"rt"` // Refresh Token HID string `json:"hid"` // Hardware ID } -// AuthTokenResponse represents the response from the authentication endpoint type AuthTokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` @@ -46,41 +48,50 @@ type Auth2faResponse struct { NextTimeInSecs int `json:"next_time_in_secs"` } -// SocketTicketRequest represents the request to get a socket ticket type SocketTicketResponse struct { Ticket string `json:"ticket"` ResponseTimestamp int64 `json:"response_timestamp"` } -// RingRestClient handles authentication and requests to Ring API -type RingRestClient struct { +type SessionResponse struct { + Profile struct { + ID int64 `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + } `json:"profile"` +} + +type RingApi struct { httpClient *http.Client authConfig *AuthConfig hardwareID string authToken *AuthTokenResponse + tokenExpiry time.Time Using2FA bool PromptFor2FA string RefreshToken string auth interface{} // EmailAuth or RefreshTokenAuth onTokenRefresh func(string) + authMutex sync.Mutex + session *SessionResponse + sessionExpiry time.Time + sessionMutex sync.Mutex + cacheKey string } -// CameraKind represents the different types of Ring cameras type CameraKind string -// CameraData contains common fields for all camera types type CameraData struct { - ID float64 `json:"id"` - Description string `json:"description"` - DeviceID string `json:"device_id"` - Kind string `json:"kind"` - LocationID string `json:"location_id"` + ID int `json:"id"` + Description string `json:"description"` + DeviceID string `json:"device_id"` + Kind string `json:"kind"` + LocationID string `json:"location_id"` } -// RingDeviceType represents different types of Ring devices type RingDeviceType string -// RingDevicesResponse represents the response from the Ring API type RingDevicesResponse struct { Doorbots []CameraData `json:"doorbots"` AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` @@ -139,23 +150,49 @@ const ( apiVersion = 11 defaultTimeout = 20 * time.Second maxRetries = 3 + sessionValidTime = 12 * time.Hour ) -// NewRingRestClient creates a new Ring client instance -func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { - client := &RingRestClient{ - httpClient: &http.Client{Timeout: defaultTimeout}, - onTokenRefresh: onTokenRefresh, - hardwareID: generateHardwareID(), - auth: auth, - } +func NewRestClient(auth interface{}, onTokenRefresh func(string)) (*RingApi, error) { + var cacheKey string + // Create cache key based on auth data switch a := auth.(type) { case RefreshTokenAuth: if a.RefreshToken == "" { return nil, fmt.Errorf("refresh token is required") } + cacheKey = "refresh:" + a.RefreshToken + case EmailAuth: + if a.Email == "" || a.Password == "" { + return nil, fmt.Errorf("email and password are required") + } + cacheKey = "email:" + a.Email + ":" + a.Password + default: + return nil, fmt.Errorf("invalid auth type") + } + cacheMutex.Lock() + defer cacheMutex.Unlock() + + if cachedClient, ok := clientCache[cacheKey]; ok { + // Check if token is not nil and not expired + if cachedClient.authToken != nil && time.Now().Before(cachedClient.tokenExpiry) { + cachedClient.onTokenRefresh = onTokenRefresh + return cachedClient, nil + } + } + + client := &RingApi{ + httpClient: &http.Client{Timeout: defaultTimeout}, + onTokenRefresh: onTokenRefresh, + hardwareID: generateHardwareID(), + auth: auth, + cacheKey: cacheKey, + } + + switch a := auth.(type) { + case RefreshTokenAuth: config, err := parseAuthConfig(a.RefreshToken) if err != nil { return nil, fmt.Errorf("failed to parse refresh token: %w", err) @@ -164,160 +201,30 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest client.authConfig = config client.hardwareID = config.HID client.RefreshToken = a.RefreshToken - case EmailAuth: - if a.Email == "" || a.Password == "" { - return nil, fmt.Errorf("email and password are required") - } - default: - return nil, fmt.Errorf("invalid auth type") } + clientCache[cacheKey] = client + return client, nil } -// Request makes an authenticated request to the Ring API -func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) { - // Ensure we have a valid auth token - if err := c.ensureAuth(); err != nil { - return nil, fmt.Errorf("authentication failed: %w", err) - } - - var bodyReader io.Reader - if body != nil { - jsonBody, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - bodyReader = bytes.NewReader(jsonBody) - } - - // Create request - req, err := http.NewRequest(method, url, bodyReader) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Set headers - req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("hardware_id", c.hardwareID) - req.Header.Set("User-Agent", "android:com.ringapp") - - // Make request with retries - var resp *http.Response - var responseBody []byte - - for attempt := 0; attempt <= maxRetries; attempt++ { - resp, err = c.httpClient.Do(req) - if err != nil { - if attempt == maxRetries { - return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err) - } - time.Sleep(5 * time.Second) - continue - } - defer resp.Body.Close() - - responseBody, err = io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - // Handle 401 by refreshing auth and retrying - if resp.StatusCode == http.StatusUnauthorized { - c.authToken = nil // Force token refresh - if attempt == maxRetries { - return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) - } - if err := c.ensureAuth(); err != nil { - return nil, fmt.Errorf("failed to refresh authentication: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) - continue - } - - // Handle other error status codes - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) - } - - break - } - - return responseBody, nil +func ClientAPI(path string) string { + return clientAPIBaseURL + path } -// ensureAuth ensures we have a valid auth token -func (c *RingRestClient) ensureAuth() error { - if c.authToken != nil { - return nil - } - - var grantData = map[string]string{ - "grant_type": "refresh_token", - "refresh_token": c.authConfig.RT, - } - - // Add common fields - grantData["client_id"] = "ring_official_android" - grantData["scope"] = "client" - - // Make auth request - body, err := json.Marshal(grantData) - if err != nil { - return fmt.Errorf("failed to marshal auth request: %w", err) - } - - req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("failed to create auth request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("hardware_id", c.hardwareID) - req.Header.Set("User-Agent", "android:com.ringapp") - req.Header.Set("2fa-support", "true") - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("auth request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusPreconditionFailed { - return fmt.Errorf("2FA required. Please see documentation for handling 2FA") - } - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) - } - - var authResp AuthTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { - return fmt.Errorf("failed to decode auth response: %w", err) - } - - // Update auth config and refresh token - c.authToken = &authResp - c.authConfig = &AuthConfig{ - RT: authResp.RefreshToken, - HID: c.hardwareID, - } - - // Encode and notify about new refresh token - if c.onTokenRefresh != nil { - newRefreshToken := encodeAuthConfig(c.authConfig) - c.onTokenRefresh(newRefreshToken) - } - - return nil +func DeviceAPI(path string) string { + return deviceAPIBaseURL + path } -// getAuth makes an authentication request to the Ring API -func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { +func CommandsAPI(path string) string { + return commandsAPIBaseURL + path +} + +func AppAPI(path string) string { + return appAPIBaseURL + path +} + +func (c *RingApi) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { var grantData map[string]string if c.authConfig != nil && twoFactorAuthCode == "" { @@ -404,60 +311,30 @@ func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, return nil, fmt.Errorf("failed to decode auth response: %w", err) } + // Refresh token and expiry c.authToken = &authResp c.authConfig = &AuthConfig{ RT: authResp.RefreshToken, HID: c.hardwareID, } + // Set token expiry (1 minute before actual expiry) + expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second + c.tokenExpiry = time.Now().Add(expiresIn) c.RefreshToken = encodeAuthConfig(c.authConfig) if c.onTokenRefresh != nil { c.onTokenRefresh(c.RefreshToken) } + // Refresh the cached client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + return c.authToken, nil } -// Helper functions for auth config encoding/decoding -func parseAuthConfig(refreshToken string) (*AuthConfig, error) { - decoded, err := base64.StdEncoding.DecodeString(refreshToken) - if err != nil { - return nil, err - } - - var config AuthConfig - if err := json.Unmarshal(decoded, &config); err != nil { - // Handle legacy format where refresh token is the raw token - return &AuthConfig{RT: refreshToken}, nil - } - - return &config, nil -} - -func encodeAuthConfig(config *AuthConfig) string { - jsonBytes, _ := json.Marshal(config) - return base64.StdEncoding.EncodeToString(jsonBytes) -} - -// API URL helpers -func ClientAPI(path string) string { - return clientAPIBaseURL + path -} - -func DeviceAPI(path string) string { - return deviceAPIBaseURL + path -} - -func CommandsAPI(path string) string { - return commandsAPIBaseURL + path -} - -func AppAPI(path string) string { - return appAPIBaseURL + path -} - -// FetchRingDevices gets all Ring devices and categorizes them -func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) { +func (c *RingApi) FetchRingDevices() (*RingDevicesResponse, error) { response, err := c.Request("GET", ClientAPI("ring_devices"), nil) if err != nil { return nil, fmt.Errorf("failed to fetch ring devices: %w", err) @@ -509,7 +386,7 @@ func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) { return &devices, nil } -func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) { +func (c *RingApi) GetSocketTicket() (*SocketTicketResponse, error) { response, err := c.Request("POST", AppAPI("clap/ticket/request/signalsocket"), nil) if err != nil { return nil, fmt.Errorf("failed to fetch socket ticket: %w", err) @@ -523,6 +400,286 @@ func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) { return &ticket, nil } +func (c *RingApi) Request(method, url string, body interface{}) ([]byte, error) { + // Ensure we have a valid session + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("session validation failed: %w", err) + } + + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(jsonBody) + } + + // Create request + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + + // Make request with retries + var resp *http.Response + var responseBody []byte + + for attempt := 0; attempt <= maxRetries; attempt++ { + resp, err = c.httpClient.Do(req) + if err != nil { + if attempt == maxRetries { + return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err) + } + time.Sleep(5 * time.Second) + continue + } + defer resp.Body.Close() + + responseBody, err = io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Handle 401 by refreshing auth and retrying + if resp.StatusCode == http.StatusUnauthorized { + // Reset token to force refresh + c.authMutex.Lock() + c.authToken = nil + c.tokenExpiry = time.Time{} // Reset token expiry + c.authMutex.Unlock() + + if attempt == maxRetries { + return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) + } + + // By 401 with Auth AND Session start over + c.sessionMutex.Lock() + c.session = nil + c.sessionExpiry = time.Time{} // Reset session expiry + c.sessionMutex.Unlock() + + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("failed to refresh session: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + continue + } + + // Handle 404 error with hardware_id reference - session issue + if resp.StatusCode == 404 && strings.Contains(url, clientAPIBaseURL) { + var errorBody map[string]interface{} + if err := json.Unmarshal(responseBody, &errorBody); err == nil { + if errorStr, ok := errorBody["error"].(string); ok && strings.Contains(errorStr, c.hardwareID) { + // Session with hardware_id not found, refresh session + c.sessionMutex.Lock() + c.session = nil + c.sessionExpiry = time.Time{} // Reset session expiry + c.sessionMutex.Unlock() + + if attempt == maxRetries { + return nil, fmt.Errorf("session refresh failed after %d retries", maxRetries) + } + + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("failed to refresh session: %w", err) + } + + continue + } + } + } + + // Handle other error status codes + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) + } + + break + } + + return responseBody, nil +} + +func (c *RingApi) ensureSession() error { + c.sessionMutex.Lock() + defer c.sessionMutex.Unlock() + + // If session is still valid, use it + if c.session != nil && time.Now().Before(c.sessionExpiry) { + return nil + } + + // Make sure we have a valid auth token + if err := c.ensureAuth(); err != nil { + return fmt.Errorf("authentication failed while creating session: %w", err) + } + + sessionPayload := map[string]interface{}{ + "device": map[string]interface{}{ + "hardware_id": c.hardwareID, + "metadata": map[string]interface{}{ + "api_version": apiVersion, + "device_model": "ring-client-go", + }, + "os": "android", + }, + } + + body, err := json.Marshal(sessionPayload) + if err != nil { + return fmt.Errorf("failed to marshal session request: %w", err) + } + + req, err := http.NewRequest("POST", ClientAPI("session"), bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("session request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var sessionResp SessionResponse + if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil { + return fmt.Errorf("failed to decode session response: %w", err) + } + + c.session = &sessionResp + c.sessionExpiry = time.Now().Add(sessionValidTime) + + // Aktualisiere den gecachten Client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + + return nil +} + +func (c *RingApi) ensureAuth() error { + c.authMutex.Lock() + defer c.authMutex.Unlock() + + // If token exists and is not expired, use it + if c.authToken != nil && time.Now().Before(c.tokenExpiry) { + return nil + } + + var grantData = map[string]string{ + "grant_type": "refresh_token", + "refresh_token": c.authConfig.RT, + } + + // Add common fields + grantData["client_id"] = "ring_official_android" + grantData["scope"] = "client" + + // Make auth request + body, err := json.Marshal(grantData) + if err != nil { + return fmt.Errorf("failed to marshal auth request: %w", err) + } + + req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create auth request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + req.Header.Set("2fa-support", "true") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("auth request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusPreconditionFailed { + return fmt.Errorf("2FA required. Please see documentation for handling 2FA") + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp AuthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return fmt.Errorf("failed to decode auth response: %w", err) + } + + // Update auth config and refresh token + c.authToken = &authResp + c.authConfig = &AuthConfig{ + RT: authResp.RefreshToken, + HID: c.hardwareID, + } + + // Set token expiry (1 minute before actual expiry) + expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second + c.tokenExpiry = time.Now().Add(expiresIn) + + // Encode and notify about new refresh token + if c.onTokenRefresh != nil { + newRefreshToken := encodeAuthConfig(c.authConfig) + c.onTokenRefresh(newRefreshToken) + } + + // Refreshn the token in the client + c.RefreshToken = encodeAuthConfig(c.authConfig) + + // Refresh the cached client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + + return nil +} + +func parseAuthConfig(refreshToken string) (*AuthConfig, error) { + decoded, err := base64.StdEncoding.DecodeString(refreshToken) + if err != nil { + return nil, err + } + + var config AuthConfig + if err := json.Unmarshal(decoded, &config); err != nil { + // Handle legacy format where refresh token is the raw token + return &AuthConfig{RT: refreshToken}, nil + } + + return &config, nil +} + +func encodeAuthConfig(config *AuthConfig) string { + jsonBytes, _ := json.Marshal(config) + return base64.StdEncoding.EncodeToString(jsonBytes) +} + func generateHardwareID() string { h := sha256.New() h.Write([]byte("ring-client-go2rtc")) diff --git a/pkg/ring/client.go b/pkg/ring/client.go index 18244a39..fb77e198 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -5,103 +5,25 @@ import ( "errors" "fmt" "net/url" - "sync" - "time" + "strconv" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/google/uuid" - "github.com/gorilla/websocket" pion "github.com/pion/webrtc/v4" ) type Client struct { - api *RingRestClient - ws *websocket.Conn + api *RingApi + wsClient *WSClient prod core.Producer - camera *CameraData + cameraID int dialogID string - sessionID string - wsMutex sync.Mutex - done chan struct{} + connected core.Waiter + closed bool } -type SessionBody struct { - DoorbotID int `json:"doorbot_id"` - SessionID string `json:"session_id"` -} - -type AnswerMessage struct { - Method string `json:"method"` // "sdp" - Body struct { - SessionBody - SDP string `json:"sdp"` - Type string `json:"type"` // "answer" - } `json:"body"` -} - -type IceCandidateMessage struct { - Method string `json:"method"` // "ice" - Body struct { - SessionBody - Ice string `json:"ice"` - MLineIndex int `json:"mlineindex"` - } `json:"body"` -} - -type SessionMessage struct { - Method string `json:"method"` // "session_created" or "session_started" - Body SessionBody `json:"body"` -} - -type PongMessage struct { - Method string `json:"method"` // "pong" - Body SessionBody `json:"body"` -} - -type NotificationMessage struct { - Method string `json:"method"` // "notification" - Body struct { - SessionBody - IsOK bool `json:"is_ok"` - Text string `json:"text"` - } `json:"body"` -} - -type StreamInfoMessage struct { - Method string `json:"method"` // "stream_info" - Body struct { - SessionBody - Transcoding bool `json:"transcoding"` - TranscodingReason string `json:"transcoding_reason"` - } `json:"body"` -} - -type CloseMessage struct { - Method string `json:"method"` // "close" - Body struct { - SessionBody - Reason struct { - Code int `json:"code"` - Text string `json:"text"` - } `json:"reason"` - } `json:"body"` -} - -type BaseMessage struct { - Method string `json:"method"` - Body map[string]any `json:"body"` -} - -// Close reason codes -const ( - CloseReasonNormalClose = 0 - CloseReasonAuthenticationFailed = 5 - CloseReasonTimeout = 6 -) - func Dial(rawURL string) (*Client, error) { - // 1. Parse URL and validate basic params u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -109,70 +31,42 @@ func Dial(rawURL string) (*Client, error) { query := u.Query() encodedToken := query.Get("refresh_token") + cameraID := query.Get("camera_id") deviceID := query.Get("device_id") _, isSnapshot := query["snapshot"] - if encodedToken == "" || deviceID == "" { + if encodedToken == "" || deviceID == "" || cameraID == "" { return nil, errors.New("ring: wrong query") } - // URL-decode the refresh token + client := &Client{ + dialogID: uuid.NewString(), + } + + client.cameraID, err = strconv.Atoi(cameraID) + if err != nil { + return nil, fmt.Errorf("ring: invalid camera_id: %w", err) + } + refreshToken, err := url.QueryUnescape(encodedToken) if err != nil { return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err) } - // Initialize Ring API client - ringAPI, err := NewRingRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil) + client.api, err = NewRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil) if err != nil { return nil, err } - // Get camera details - devices, err := ringAPI.FetchRingDevices() - if err != nil { - return nil, err - } - - var camera *CameraData - for _, cam := range devices.AllCameras { - if fmt.Sprint(cam.DeviceID) == deviceID { - camera = &cam - break - } - } - if camera == nil { - return nil, errors.New("ring: camera not found") - } - - // Create base client - client := &Client{ - api: ringAPI, - camera: camera, - dialogID: uuid.NewString(), - done: make(chan struct{}), - } - - // Check if snapshot request + // Snapshot Flow if isSnapshot { - client.prod = NewSnapshotProducer(ringAPI, camera) + client.prod = NewSnapshotProducer(client.api, client.cameraID) return client, nil } - // If not snapshot, continue with WebRTC setup - ticket, err := ringAPI.GetSocketTicket() - if err != nil { - return nil, err - } - - // Create WebSocket connection - wsURL := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s", - uuid.NewString(), url.QueryEscape(ticket.Ticket)) - - client.ws, _, err = websocket.DefaultDialer.Dial(wsURL, map[string][]string{ - "User-Agent": {"android:com.ringapp"}, - }) + client.wsClient, err = StartWebsocket(client.cameraID, client.api) if err != nil { + client.Stop() return nil, err } @@ -196,13 +90,13 @@ func Dial(rawURL string) (*Client, error) { api, err := webrtc.NewAPI() if err != nil { - client.ws.Close() + client.Stop() return nil, err } pc, err := api.NewPeerConnection(conf) if err != nil { - client.ws.Close() + client.Stop() return nil, err } @@ -212,16 +106,27 @@ func Dial(rawURL string) (*Client, error) { // protect from blocking on errors defer sendOffer.Done(nil) - // waiter will wait PC error or WS error or nil (connection OK) - var connState core.Waiter - prod := webrtc.NewConn(pc) prod.FormatName = "ring/webrtc" prod.Mode = core.ModeActiveProducer prod.Protocol = "ws" prod.URL = rawURL - client.prod = prod + client.wsClient.onMessage = func(msg WSMessage) { + client.onWSMessage(msg) + } + + client.wsClient.onError = func(err error) { + // fmt.Printf("ring: error: %s\n", err.Error()) + client.Stop() + client.connected.Done(err) + } + + client.wsClient.onClose = func() { + // fmt.Println("ring: disconnect") + client.Stop() + client.connected.Done(errors.New("ring: disconnect")) + } prod.Listen(func(msg any) { switch msg := msg.(type) { @@ -240,22 +145,28 @@ func Dial(rawURL string) (*Client, error) { "mlineindex": iceCandidate.SDPMLineIndex, } - if err = client.sendSessionMessage("ice", icePayload); err != nil { - connState.Done(err) + if err = client.wsClient.sendSessionMessage("ice", icePayload); err != nil { + client.connected.Done(err) return } case pion.PeerConnectionState: switch msg { + case pion.PeerConnectionStateNew: + break case pion.PeerConnectionStateConnecting: + break case pion.PeerConnectionStateConnected: - connState.Done(nil) + client.connected.Done(nil) default: - connState.Done(errors.New("ring: " + msg.String())) + client.Stop() + client.connected.Done(errors.New("ring: " + msg.String())) } } }) + client.prod = prod + // Setup media configuration medias := []*core.Media{ { @@ -297,186 +208,103 @@ func Dial(rawURL string) (*Client, error) { "sdp": offer, } - if err = client.sendSessionMessage("live_view", offerPayload); err != nil { + if err = client.wsClient.sendSessionMessage("live_view", offerPayload); err != nil { client.Stop() return nil, err } sendOffer.Done(nil) - // Ring expects a ping message every 5 seconds - go client.startPingLoop(pc) - go client.startMessageLoop(&connState) - - if err = connState.Wait(); err != nil { + if err = client.connected.Wait(); err != nil { return nil, err } return client, nil } -func (c *Client) startPingLoop(pc *pion.PeerConnection) { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() +func (c *Client) onWSMessage(msg WSMessage) { + rawMsg, _ := json.Marshal(msg) - for { - select { - case <-c.done: - return - case <-ticker.C: - if pc.ConnectionState() == pion.PeerConnectionStateConnected { - if err := c.sendSessionMessage("ping", nil); err != nil { - return - } - } + // fmt.Printf("ring: onWSMessage: %s\n", string(rawMsg)) + + // check if "doorbot_id" is present + if _, ok := msg.Body["doorbot_id"]; !ok { + return + } + + // check if the message is from the correct doorbot + doorbotID := msg.Body["doorbot_id"].(float64) + if int(doorbotID) != c.cameraID { + return + } + + if msg.Method == "session_created" || msg.Method == "session_started" { + if _, ok := msg.Body["session_id"]; ok && c.wsClient.sessionID == "" { + c.wsClient.sessionID = msg.Body["session_id"].(string) } } -} -func (c *Client) startMessageLoop(connState *core.Waiter) { - var err error - - // will be closed when conn will be closed - defer func() { - connState.Done(err) - }() - - for { - select { - case <-c.done: + // check if the message is from the correct session + if _, ok := msg.Body["session_id"]; ok { + if msg.Body["session_id"].(string) != c.wsClient.sessionID { return - default: - var res BaseMessage - if err = c.ws.ReadJSON(&res); err != nil { - select { - case <-c.done: - return - default: - } + } + } + switch msg.Method { + case "sdp": + if prod, ok := c.prod.(*webrtc.Conn); ok { + // Get answer + var msg AnswerMessage + if err := json.Unmarshal(rawMsg, &msg); err != nil { c.Stop() + c.connected.Done(err) return } - // check if "doorbot_id" is present - if _, ok := res.Body["doorbot_id"]; !ok { - continue - } - - // check if the message is from the correct doorbot - doorbotID := res.Body["doorbot_id"].(float64) - if doorbotID != float64(c.camera.ID) { - continue - } - - // check if the message is from the correct session - if res.Method == "session_created" || res.Method == "session_started" { - if _, ok := res.Body["session_id"]; ok && c.sessionID == "" { - c.sessionID = res.Body["session_id"].(string) - } - } - - if _, ok := res.Body["session_id"]; ok { - if res.Body["session_id"].(string) != c.sessionID { - continue - } - } - - rawMsg, _ := json.Marshal(res) - - switch res.Method { - case "sdp": - if prod, ok := c.prod.(*webrtc.Conn); ok { - // Get answer - var msg AnswerMessage - if err = json.Unmarshal(rawMsg, &msg); err != nil { - c.Stop() - return - } - if err = prod.SetAnswer(msg.Body.SDP); err != nil { - c.Stop() - return - } - if err = c.activateSession(); err != nil { - c.Stop() - return - } - } - - case "ice": - if prod, ok := c.prod.(*webrtc.Conn); ok { - // Continue to receiving candidates - var msg IceCandidateMessage - if err = json.Unmarshal(rawMsg, &msg); err != nil { - break - } - - // check for empty ICE candidate - if msg.Body.Ice == "" { - break - } - - if err = prod.AddCandidate(msg.Body.Ice); err != nil { - c.Stop() - return - } - } - - case "close": + if err := prod.SetAnswer(msg.Body.SDP); err != nil { c.Stop() + c.connected.Done(err) return + } - case "pong": - // Ignore - continue + if err := c.wsClient.activateSession(); err != nil { + c.Stop() + c.connected.Done(err) + return + } + + prod.SDP = msg.Body.SDP + } + + case "ice": + if prod, ok := c.prod.(*webrtc.Conn); ok { + var msg IceCandidateMessage + if err := json.Unmarshal(rawMsg, &msg); err != nil { + break + } + + // Skip empty candidates + if msg.Body.Ice == "" { + break + } + + if err := prod.AddCandidate(msg.Body.Ice); err != nil { + c.Stop() + c.connected.Done(err) + return } } + + case "close": + c.Stop() + c.connected.Done(errors.New("ring: close")) + + case "pong": + // Ignore } } -func (c *Client) activateSession() error { - if err := c.sendSessionMessage("activate_session", nil); err != nil { - return err - } - - streamPayload := map[string]interface{}{ - "audio_enabled": true, - "video_enabled": true, - } - - if err := c.sendSessionMessage("stream_options", streamPayload); err != nil { - return err - } - - return nil -} - -func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error { - c.wsMutex.Lock() - defer c.wsMutex.Unlock() - - if body == nil { - body = make(map[string]interface{}) - } - - body["doorbot_id"] = c.camera.ID - if c.sessionID != "" { - body["session_id"] = c.sessionID - } - - msg := map[string]interface{}{ - "method": method, - "dialog_id": c.dialogID, - "body": body, - } - - if err := c.ws.WriteJSON(msg); err != nil { - return err - } - - return nil -} - func (c *Client) GetMedias() []*core.Media { return c.prod.GetMedias() } @@ -492,7 +320,7 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece speakerPayload := map[string]interface{}{ "stealth_mode": false, } - _ = c.sendSessionMessage("camera_options", speakerPayload) + _ = c.wsClient.sendSessionMessage("camera_options", speakerPayload) } return webrtcProd.AddTrack(media, codec, track) } @@ -505,37 +333,23 @@ func (c *Client) Start() error { } func (c *Client) Stop() error { - select { - case <-c.done: + if c.closed { return nil - default: - close(c.done) } + c.closed = true + if c.prod != nil { _ = c.prod.Stop() } - if c.ws != nil { - closePayload := map[string]interface{}{ - "reason": map[string]interface{}{ - "code": CloseReasonNormalClose, - "text": "", - }, - } - - _ = c.sendSessionMessage("close", closePayload) - _ = c.ws.Close() - c.ws = nil + if c.wsClient != nil { + _ = c.wsClient.Close() } return nil } func (c *Client) MarshalJSON() ([]byte, error) { - if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { - return webrtcProd.MarshalJSON() - } - return json.Marshal(c.prod) } diff --git a/pkg/ring/snapshot.go b/pkg/ring/snapshot.go index f64e4f79..b52eadac 100644 --- a/pkg/ring/snapshot.go +++ b/pkg/ring/snapshot.go @@ -10,11 +10,11 @@ import ( type SnapshotProducer struct { core.Connection - client *RingRestClient - camera *CameraData + client *RingApi + cameraID int } -func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer { +func NewSnapshotProducer(client *RingApi, cameraID int) *SnapshotProducer { return &SnapshotProducer{ Connection: core.Connection{ ID: core.NewID(), @@ -35,14 +35,13 @@ func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotPr }, }, }, - client: client, - camera: camera, + client: client, + cameraID: cameraID, } } func (p *SnapshotProducer) Start() error { - // Fetch snapshot - response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", int(p.camera.ID)), nil) + response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", p.cameraID), nil) if err != nil { return err } diff --git a/pkg/ring/ws.go b/pkg/ring/ws.go new file mode 100644 index 00000000..51e72fe6 --- /dev/null +++ b/pkg/ring/ws.go @@ -0,0 +1,265 @@ +package ring + +import ( + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type SessionBody struct { + DoorbotID int `json:"doorbot_id"` + SessionID string `json:"session_id"` +} + +type AnswerMessage struct { + Method string `json:"method"` // "sdp" + Body struct { + SessionBody + SDP string `json:"sdp"` + Type string `json:"type"` // "answer" + } `json:"body"` +} + +type IceCandidateMessage struct { + Method string `json:"method"` // "ice" + Body struct { + SessionBody + Ice string `json:"ice"` + MLineIndex int `json:"mlineindex"` + } `json:"body"` +} + +type SessionMessage struct { + Method string `json:"method"` // "session_created" or "session_started" + Body SessionBody `json:"body"` +} + +type PongMessage struct { + Method string `json:"method"` // "pong" + Body SessionBody `json:"body"` +} + +type NotificationMessage struct { + Method string `json:"method"` // "notification" + Body struct { + SessionBody + IsOK bool `json:"is_ok"` + Text string `json:"text"` + } `json:"body"` +} + +type StreamInfoMessage struct { + Method string `json:"method"` // "stream_info" + Body struct { + SessionBody + Transcoding bool `json:"transcoding"` + TranscodingReason string `json:"transcoding_reason"` + } `json:"body"` +} + +type CloseRequest struct { + Method string `json:"method"` // "close" + Body struct { + SessionBody + Reason struct { + Code int `json:"code"` + Text string `json:"text"` + } `json:"reason"` + } `json:"body"` +} + +type WSMessage struct { + Method string `json:"method"` + Body map[string]any `json:"body"` +} + +type WSClient struct { + ws *websocket.Conn + api *RingApi + wsMutex sync.Mutex + cameraID int + dialogID string + sessionID string + + onMessage func(msg WSMessage) + onError func(err error) + onClose func() + + closed chan struct{} +} + +const ( + CloseReasonNormalClose = 0 + CloseReasonAuthenticationFailed = 5 + CloseReasonTimeout = 6 +) + +func StartWebsocket(cameraID int, api *RingApi) (*WSClient, error) { + client := &WSClient{ + api: api, + cameraID: cameraID, + dialogID: uuid.NewString(), + closed: make(chan struct{}), + } + + ticket, err := client.api.GetSocketTicket() + if err != nil { + return nil, err + } + + url := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s", + uuid.NewString(), url.QueryEscape(ticket.Ticket)) + + httpHeader := http.Header{} + httpHeader.Set("User-Agent", "android:com.ringapp") + + client.ws, _, err = websocket.DefaultDialer.Dial(url, httpHeader) + if err != nil { + return nil, err + } + + client.ws.SetCloseHandler(func(code int, text string) error { + client.onWsClose() + return nil + }) + + go client.startPingLoop() + go client.startMessageLoop() + + return client, nil +} + +func (c *WSClient) Close() error { + select { + case <-c.closed: + return nil + default: + close(c.closed) + } + + closePayload := map[string]interface{}{ + "reason": map[string]interface{}{ + "code": CloseReasonNormalClose, + "text": "", + }, + } + + _ = c.sendSessionMessage("close", closePayload) + + return c.ws.Close() +} + +func (c *WSClient) startPingLoop() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-c.closed: + return + case <-ticker.C: + if err := c.sendSessionMessage("ping", nil); err != nil { + return + } + } + } +} + +func (c *WSClient) startMessageLoop() { + for { + select { + case <-c.closed: + return + default: + var res WSMessage + if err := c.ws.ReadJSON(&res); err != nil { + select { + case <-c.closed: + // Ignore error if closed + default: + c.onWsError(err) + } + + return + } + + c.onWsMessage(res) + } + } +} + +func (c *WSClient) activateSession() error { + if err := c.sendSessionMessage("activate_session", nil); err != nil { + return err + } + + streamPayload := map[string]interface{}{ + "audio_enabled": true, + "video_enabled": true, + } + + if err := c.sendSessionMessage("stream_options", streamPayload); err != nil { + return err + } + + return nil +} + +func (c *WSClient) sendSessionMessage(method string, payload map[string]interface{}) error { + select { + case <-c.closed: + return nil + default: + // continue + } + + c.wsMutex.Lock() + defer c.wsMutex.Unlock() + + if payload == nil { + payload = make(map[string]interface{}) + } + + payload["doorbot_id"] = c.cameraID + if c.sessionID != "" { + payload["session_id"] = c.sessionID + } + + msg := map[string]interface{}{ + "method": method, + "dialog_id": c.dialogID, + "body": payload, + } + + // rawMsg, _ := json.Marshal(msg) + // fmt.Printf("ring: sendSessionMessage: %s: %s\n", method, string(rawMsg)) + + if err := c.ws.WriteJSON(msg); err != nil { + return err + } + + return nil +} + +func (c *WSClient) onWsMessage(msg WSMessage) { + if c.onMessage != nil { + c.onMessage(msg) + } +} + +func (c *WSClient) onWsError(err error) { + if c.onError != nil { + c.onError(err) + } +} + +func (c *WSClient) onWsClose() { + if c.onClose != nil { + c.onClose() + } +} diff --git a/www/add.html b/www/add.html index c8808736..53d6b3dc 100644 --- a/www/add.html +++ b/www/add.html @@ -254,25 +254,30 @@ async function handleRingAuth(ev) { ev.preventDefault(); + + const table = document.getElementById('ring-table'); + table.innerText = 'loading...'; + const query = new URLSearchParams(new FormData(ev.target)); const url = new URL('api/ring?' + query.toString(), location.href); const r = await fetch(url, {cache: 'no-cache'}); + + if (!r.ok) { + table.innerText = (await r.text()) || 'Unknown error'; + return; + } + const data = await r.json(); + table.innerText = ''; + if (data.needs_2fa) { document.getElementById('tfa-field').style.display = 'block'; document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code'; return; } - if (!r.ok) { - const table = document.getElementById('ring-table'); - table.innerText = data.error || 'Unknown error'; - return; - } - - const table = document.getElementById('ring-table'); drawTable(table, data); } diff --git a/www/video-rtc.js b/www/video-rtc.js index fb872b45..8ecbce72 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -185,7 +185,7 @@ export class VideoRTC extends HTMLElement { /** @param {Function} isSupported */ codecs(isSupported) { return this.CODECS - .filter(codec => this.media.indexOf(codec.indexOf('vc1') > 0 ? 'video' : 'audio') >= 0) + .filter(codec => this.media.includes(codec.includes('vc1') ? 'video' : 'audio')) .filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join(); } @@ -350,23 +350,23 @@ export class VideoRTC extends HTMLElement { const modes = []; - if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) { + if (this.mode.includes('mse') && ('MediaSource' in window || 'ManagedMediaSource' in window)) { modes.push('mse'); this.onmse(); - } else if (this.mode.indexOf('hls') >= 0 && this.video.canPlayType('application/vnd.apple.mpegurl')) { + } else if (this.mode.includes('hls') && this.video.canPlayType('application/vnd.apple.mpegurl')) { modes.push('hls'); this.onhls(); - } else if (this.mode.indexOf('mp4') >= 0) { + } else if (this.mode.includes('mp4')) { modes.push('mp4'); this.onmp4(); } - if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) { + if (this.mode.includes('webrtc') && 'RTCPeerConnection' in window) { modes.push('webrtc'); this.onwebrtc(); } - if (this.mode.indexOf('mjpeg') >= 0) { + if (this.mode.includes('mjpeg')) { if (modes.length) { this.onmessage['mjpeg'] = msg => { if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return; @@ -490,7 +490,7 @@ export class VideoRTC extends HTMLElement { const pc = new RTCPeerConnection(this.pcConfig); pc.addEventListener('icecandidate', ev => { - if (ev.candidate && this.mode.indexOf('webrtc/tcp') >= 0 && ev.candidate.protocol === 'udp') return; + if (ev.candidate && this.mode.includes('webrtc/tcp') && ev.candidate.protocol === 'udp') return; const candidate = ev.candidate ? ev.candidate.toJSON().candidate : ''; this.send({type: 'webrtc/candidate', value: candidate}); @@ -518,7 +518,7 @@ export class VideoRTC extends HTMLElement { this.onmessage['webrtc'] = msg => { switch (msg.type) { case 'webrtc/candidate': - if (this.mode.indexOf('webrtc/tcp') >= 0 && msg.value.indexOf(' udp ') > 0) return; + if (this.mode.includes('webrtc/tcp') && msg.value.includes(' udp ')) return; pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => { console.warn(er); @@ -530,7 +530,7 @@ export class VideoRTC extends HTMLElement { }); break; case 'error': - if (msg.value.indexOf('webrtc/offer') < 0) return; + if (!msg.value.includes('webrtc/offer')) return; pc.close(); } }; @@ -549,7 +549,7 @@ export class VideoRTC extends HTMLElement { */ async createOffer(pc) { try { - if (this.media.indexOf('microphone') >= 0) { + if (this.media.includes('microphone')) { const media = await navigator.mediaDevices.getUserMedia({audio: true}); media.getTracks().forEach(track => { pc.addTransceiver(track, {direction: 'sendonly'}); @@ -560,7 +560,7 @@ export class VideoRTC extends HTMLElement { } for (const kind of ['video', 'audio']) { - if (this.media.indexOf(kind) >= 0) { + if (this.media.includes(kind)) { pc.addTransceiver(kind, {direction: 'recvonly'}); } } @@ -580,12 +580,16 @@ export class VideoRTC extends HTMLElement { /** @type {MediaStream} */ const stream = video2.srcObject; - if (stream.getVideoTracks().length > 0) rtcPriority += 0x220; + if (stream.getVideoTracks().length > 0) { + // not the best, but a pretty simple way to check a codec + const isH265Supported = this.pc.remoteDescription.sdp.includes('H265/90000'); + rtcPriority += isH265Supported ? 0x240 : 0x220; + } if (stream.getAudioTracks().length > 0) rtcPriority += 0x102; - if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230; - if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210; - if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101; + if (this.mseCodecs.includes('hvc1.')) msePriority += 0x230; + if (this.mseCodecs.includes('avc1.')) msePriority += 0x210; + if (this.mseCodecs.includes('mp4a.')) msePriority += 0x101; if (rtcPriority >= msePriority) { this.video.srcObject = stream;