From 7836f2e47f8895e49d1e3fcbc96b9ff638c88600 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 11 Mar 2025 01:50:41 +0100 Subject: [PATCH 01/41] check h265 --- www/video-rtc.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index fb872b45..518e242f 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -583,7 +583,7 @@ export class VideoRTC extends HTMLElement { if (stream.getVideoTracks().length > 0) rtcPriority += 0x220; if (stream.getAudioTracks().length > 0) rtcPriority += 0x102; - if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230; + if (this.mseCodecs.indexOf('hvc1.') >= 0 && !VideoRTC.isH265Supported()) msePriority += 0x230; if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210; if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101; @@ -664,6 +664,23 @@ export class VideoRTC extends HTMLElement { this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)}); } + static isH265Supported() { + try { + const videoCodecs = RTCRtpSender?.getCapabilities('video')?.codecs; + + if (!videoCodecs) { + return false; + } + + return videoCodecs.some(codec => + codec.mimeType.toLowerCase().includes('h265') || + codec.mimeType.toLowerCase().includes('hevc') + ); + } catch { + return false; + } + } + static btoa(buffer) { const bytes = new Uint8Array(buffer); const len = bytes.byteLength; From b28ffa9543e5d71bcfd1e45f012af96a41ae99c8 Mon Sep 17 00:00:00 2001 From: seydx Date: Tue, 11 Mar 2025 01:52:16 +0100 Subject: [PATCH 02/41] indentation --- www/video-rtc.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index 518e242f..143ee5a1 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -673,8 +673,8 @@ export class VideoRTC extends HTMLElement { } return videoCodecs.some(codec => - codec.mimeType.toLowerCase().includes('h265') || - codec.mimeType.toLowerCase().includes('hevc') + codec.mimeType.toLowerCase().includes('h265') || + codec.mimeType.toLowerCase().includes('hevc') ); } catch { return false; From ac96b64c64d04df8d0882bbd31f6978310d60f56 Mon Sep 17 00:00:00 2001 From: seydx Date: Sun, 16 Mar 2025 14:16:01 +0100 Subject: [PATCH 03/41] change codec priority handling for h265 --- www/video-rtc.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index 143ee5a1..7efa0a57 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -583,7 +583,10 @@ export class VideoRTC extends HTMLElement { if (stream.getVideoTracks().length > 0) rtcPriority += 0x220; if (stream.getAudioTracks().length > 0) rtcPriority += 0x102; - if (this.mseCodecs.indexOf('hvc1.') >= 0 && !VideoRTC.isH265Supported()) msePriority += 0x230; + if (this.mseCodecs.indexOf('hvc1.')) { + if (VideoRTC.isH265Supported()) rtcPriority += 0x230; + else msePriority += 0x230; + } if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210; if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101; From 124556f4db2f13e53a6c759cd1ab0761dce94997 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 8 May 2025 16:09:04 +0200 Subject: [PATCH 04/41] ring: skip refetching cameras to increase loading speed and refactor ring url --- internal/ring/ring.go | 3 +++ pkg/ring/api.go | 2 +- pkg/ring/client.go | 34 ++++++++++++---------------------- pkg/ring/snapshot.go | 8 ++++---- 4 files changed, 20 insertions(+), 27 deletions(-) diff --git a/internal/ring/ring.go b/internal/ring/ring.go index 673ea480..e1615151 100644 --- a/internal/ring/ring.go +++ b/internal/ring/ring.go @@ -5,6 +5,8 @@ import ( "net/http" "net/url" + "fmt" + "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" @@ -83,6 +85,7 @@ func apiRing(w http.ResponseWriter, r *http.Request) { 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/ring/api.go b/pkg/ring/api.go index ed69465f..3b67173d 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -70,7 +70,7 @@ type CameraKind string // CameraData contains common fields for all camera types type CameraData struct { - ID float64 `json:"id"` + ID int `json:"id"` Description string `json:"description"` DeviceID string `json:"device_id"` Kind string `json:"kind"` diff --git a/pkg/ring/client.go b/pkg/ring/client.go index 18244a39..ccd17743 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "strconv" "sync" "time" @@ -19,7 +20,7 @@ type Client struct { api *RingRestClient ws *websocket.Conn prod core.Producer - camera *CameraData + cameraID int dialogID string sessionID string wsMutex sync.Mutex @@ -109,6 +110,7 @@ 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"] @@ -116,6 +118,11 @@ func Dial(rawURL string) (*Client, error) { return nil, errors.New("ring: wrong query") } + camID, err := strconv.Atoi(cameraID) + if err != nil { + return nil, fmt.Errorf("ring: invalid camera_id: %w", err) + } + // URL-decode the refresh token refreshToken, err := url.QueryUnescape(encodedToken) if err != nil { @@ -128,34 +135,17 @@ func Dial(rawURL string) (*Client, error) { 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, + cameraID: camID, dialogID: uuid.NewString(), done: make(chan struct{}), } // Check if snapshot request if isSnapshot { - client.prod = NewSnapshotProducer(ringAPI, camera) + client.prod = NewSnapshotProducer(ringAPI, cameraID) return client, nil } @@ -365,7 +355,7 @@ func (c *Client) startMessageLoop(connState *core.Waiter) { // check if the message is from the correct doorbot doorbotID := res.Body["doorbot_id"].(float64) - if doorbotID != float64(c.camera.ID) { + if int(doorbotID) != c.cameraID { continue } @@ -459,7 +449,7 @@ func (c *Client) sendSessionMessage(method string, body map[string]interface{}) body = make(map[string]interface{}) } - body["doorbot_id"] = c.camera.ID + body["doorbot_id"] = c.cameraID if c.sessionID != "" { body["session_id"] = c.sessionID } diff --git a/pkg/ring/snapshot.go b/pkg/ring/snapshot.go index f64e4f79..6d1a97bf 100644 --- a/pkg/ring/snapshot.go +++ b/pkg/ring/snapshot.go @@ -11,10 +11,10 @@ type SnapshotProducer struct { core.Connection client *RingRestClient - camera *CameraData + cameraID string } -func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer { +func NewSnapshotProducer(client *RingRestClient, cameraID string) *SnapshotProducer { return &SnapshotProducer{ Connection: core.Connection{ ID: core.NewID(), @@ -36,13 +36,13 @@ func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotPr }, }, client: client, - camera: camera, + 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/%s", p.cameraID), nil) if err != nil { return err } From 2eef7bdbd3b46c6c25529b315e95a1baa3f0fc71 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 8 May 2025 17:38:09 +0200 Subject: [PATCH 05/41] ring: implement session management and caching --- pkg/ring/api.go | 222 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 199 insertions(+), 23 deletions(-) diff --git a/pkg/ring/api.go b/pkg/ring/api.go index 3b67173d..2a905d50 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]*RingRestClient{} +var cacheMutex sync.Mutex + type RefreshTokenAuth struct { RefreshToken string } @@ -52,17 +56,35 @@ type SocketTicketResponse struct { ResponseTimestamp int64 `json:"response_timestamp"` } +// SessionResponse repesents the response from the session endpoint +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"` +} + // RingRestClient handles authentication and requests to Ring API type RingRestClient 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 + + // Cache-Schlüssel für diese Instanz + cacheKey string } // CameraKind represents the different types of Ring cameras @@ -139,23 +161,50 @@ const ( apiVersion = 11 defaultTimeout = 20 * time.Second maxRetries = 3 + sessionValidTime = 12 * time.Hour ) -// NewRingRestClient creates a new Ring client instance +// NewRingRestClient creates a new Ring client instance with caching func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { - client := &RingRestClient{ - httpClient: &http.Client{Timeout: defaultTimeout}, - onTokenRefresh: onTokenRefresh, - hardwareID: generateHardwareID(), - auth: auth, - } - + 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 := &RingRestClient{ + 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,22 +213,18 @@ 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) + // 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 @@ -226,17 +271,54 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, // Handle 401 by refreshing auth and retrying if resp.StatusCode == http.StatusUnauthorized { - c.authToken = nil // Force token refresh + // 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) } - if err := c.ensureAuth(); err != nil { - return nil, fmt.Errorf("failed to refresh authentication: %w", err) + + // 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)) @@ -248,9 +330,82 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, return responseBody, nil } -// ensureAuth ensures we have a valid auth token +// ensureSession makes sure we have a valid session +func (c *RingRestClient) 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 +} + +// ensureAuth ensures we have a valid auth token with expiration tracking func (c *RingRestClient) ensureAuth() error { - if c.authToken != nil { + 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 } @@ -306,12 +461,24 @@ func (c *RingRestClient) ensureAuth() error { 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 } @@ -404,16 +571,25 @@ 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 } @@ -542,4 +718,4 @@ func interfaceSlice(slice interface{}) []CameraData { } } return ret -} +} \ No newline at end of file From edfa09bb9fd64a2308d5873099d4c6feba6d53d0 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 10 May 2025 19:04:47 +0200 Subject: [PATCH 06/41] ring: update peer connection state handling and pass sdo to producer --- pkg/ring/client.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/ring/client.go b/pkg/ring/client.go index ccd17743..da1b2ce8 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -237,7 +237,10 @@ func Dial(rawURL string) (*Client, error) { case pion.PeerConnectionState: switch msg { + case pion.PeerConnectionStateNew: + break case pion.PeerConnectionStateConnecting: + break case pion.PeerConnectionStateConnected: connState.Done(nil) default: @@ -391,6 +394,8 @@ func (c *Client) startMessageLoop(connState *core.Waiter) { c.Stop() return } + + prod.SDP = msg.Body.SDP } case "ice": From adb1b21e81b23628eb6ec80a4abc6dfdad32da29 Mon Sep 17 00:00:00 2001 From: seydx Date: Sat, 17 May 2025 16:37:12 +0200 Subject: [PATCH 07/41] format --- pkg/ring/api.go | 54 ++++++++++++++++++++++---------------------- pkg/ring/snapshot.go | 4 ++-- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/pkg/ring/api.go b/pkg/ring/api.go index 2a905d50..62ac7827 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -59,10 +59,10 @@ type SocketTicketResponse struct { // SessionResponse repesents the response from the session endpoint type SessionResponse struct { Profile struct { - ID int64 `json:"id"` - Email string `json:"email"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + ID int64 `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` } `json:"profile"` } @@ -82,9 +82,9 @@ type RingRestClient struct { session *SessionResponse sessionExpiry time.Time sessionMutex sync.Mutex - + // Cache-Schlüssel für diese Instanz - cacheKey string + cacheKey string } // CameraKind represents the different types of Ring cameras @@ -92,11 +92,11 @@ type CameraKind string // CameraData contains common fields for all camera types type CameraData struct { - ID int `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 @@ -167,7 +167,7 @@ const ( // NewRingRestClient creates a new Ring client instance with caching func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { var cacheKey string - + // Create cache key based on auth data switch a := auth.(type) { case RefreshTokenAuth: @@ -183,10 +183,10 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest 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) { @@ -194,7 +194,7 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest return cachedClient, nil } } - + client := &RingRestClient{ httpClient: &http.Client{Timeout: defaultTimeout}, onTokenRefresh: onTokenRefresh, @@ -216,7 +216,7 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest } clientCache[cacheKey] = client - + return client, nil } @@ -276,21 +276,21 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, 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 } @@ -305,15 +305,15 @@ func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, 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 } } @@ -461,7 +461,7 @@ func (c *RingRestClient) ensureAuth() error { 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) @@ -471,7 +471,7 @@ func (c *RingRestClient) ensureAuth() error { newRefreshToken := encodeAuthConfig(c.authConfig) c.onTokenRefresh(newRefreshToken) } - + // Refreshn the token in the client c.RefreshToken = encodeAuthConfig(c.authConfig) @@ -585,7 +585,7 @@ func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, if c.onTokenRefresh != nil { c.onTokenRefresh(c.RefreshToken) } - + // Refresh the cached client cacheMutex.Lock() clientCache[c.cacheKey] = c @@ -718,4 +718,4 @@ func interfaceSlice(slice interface{}) []CameraData { } } return ret -} \ No newline at end of file +} diff --git a/pkg/ring/snapshot.go b/pkg/ring/snapshot.go index 6d1a97bf..727a5245 100644 --- a/pkg/ring/snapshot.go +++ b/pkg/ring/snapshot.go @@ -10,7 +10,7 @@ import ( type SnapshotProducer struct { core.Connection - client *RingRestClient + client *RingRestClient cameraID string } @@ -35,7 +35,7 @@ func NewSnapshotProducer(client *RingRestClient, cameraID string) *SnapshotProdu }, }, }, - client: client, + client: client, cameraID: cameraID, } } From c90fcd1ce16e613fc520980f4aadc8ab644be419 Mon Sep 17 00:00:00 2001 From: seydx Date: Wed, 21 May 2025 13:16:49 +0200 Subject: [PATCH 08/41] refactor --- internal/ring/ring.go | 21 +- pkg/ring/api.go | 615 ++++++++++++++++++++---------------------- pkg/ring/client.go | 405 ++++++++-------------------- pkg/ring/snapshot.go | 9 +- pkg/ring/ws.go | 265 ++++++++++++++++++ www/add.html | 19 +- 6 files changed, 702 insertions(+), 632 deletions(-) create mode 100644 pkg/ring/ws.go diff --git a/internal/ring/ring.go b/internal/ring/ring.go index e1615151..7fdb284f 100644 --- a/internal/ring/ring.go +++ b/internal/ring/ring.go @@ -1,7 +1,6 @@ package ring import ( - "encoding/json" "net/http" "net/url" @@ -23,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 != "" { @@ -32,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) @@ -46,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, }) @@ -55,31 +54,33 @@ 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) diff --git a/pkg/ring/api.go b/pkg/ring/api.go index 62ac7827..ea7c95ad 100644 --- a/pkg/ring/api.go +++ b/pkg/ring/api.go @@ -15,7 +15,7 @@ import ( "time" ) -var clientCache = map[string]*RingRestClient{} +var clientCache = map[string]*RingApi{} var cacheMutex sync.Mutex type RefreshTokenAuth struct { @@ -27,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"` @@ -50,13 +48,11 @@ 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"` } -// SessionResponse repesents the response from the session endpoint type SessionResponse struct { Profile struct { ID int64 `json:"id"` @@ -66,8 +62,7 @@ type SessionResponse struct { } `json:"profile"` } -// RingRestClient handles authentication and requests to Ring API -type RingRestClient struct { +type RingApi struct { httpClient *http.Client authConfig *AuthConfig hardwareID string @@ -82,15 +77,11 @@ type RingRestClient struct { session *SessionResponse sessionExpiry time.Time sessionMutex sync.Mutex - - // Cache-Schlüssel für diese Instanz - cacheKey string + 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 int `json:"id"` Description string `json:"description"` @@ -99,10 +90,8 @@ type CameraData struct { 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"` @@ -164,8 +153,7 @@ const ( sessionValidTime = 12 * time.Hour ) -// NewRingRestClient creates a new Ring client instance with caching -func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { +func NewRestClient(auth interface{}, onTokenRefresh func(string)) (*RingApi, error) { var cacheKey string // Create cache key based on auth data @@ -195,7 +183,7 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest } } - client := &RingRestClient{ + client := &RingApi{ httpClient: &http.Client{Timeout: defaultTimeout}, onTokenRefresh: onTokenRefresh, hardwareID: generateHardwareID(), @@ -220,271 +208,23 @@ func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRest 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 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 ClientAPI(path string) string { + return clientAPIBaseURL + path } -// ensureSession makes sure we have a valid session -func (c *RingRestClient) 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 DeviceAPI(path string) string { + return deviceAPIBaseURL + path } -// ensureAuth ensures we have a valid auth token with expiration tracking -func (c *RingRestClient) 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 CommandsAPI(path string) string { + return commandsAPIBaseURL + path } -// getAuth makes an authentication request to the Ring API -func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { +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 == "" { @@ -594,46 +334,7 @@ func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, 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) @@ -685,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) @@ -699,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 da1b2ce8..fb77e198 100644 --- a/pkg/ring/client.go +++ b/pkg/ring/client.go @@ -6,103 +6,24 @@ import ( "fmt" "net/url" "strconv" - "sync" - "time" "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 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 @@ -114,55 +35,38 @@ func Dial(rawURL string) (*Client, error) { deviceID := query.Get("device_id") _, isSnapshot := query["snapshot"] - if encodedToken == "" || deviceID == "" { + if encodedToken == "" || deviceID == "" || cameraID == "" { return nil, errors.New("ring: wrong query") } - camID, err := strconv.Atoi(cameraID) + client := &Client{ + dialogID: uuid.NewString(), + } + + client.cameraID, err = strconv.Atoi(cameraID) if err != nil { return nil, fmt.Errorf("ring: invalid camera_id: %w", err) } - // URL-decode the refresh token 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 } - // Create base client - client := &Client{ - api: ringAPI, - cameraID: camID, - dialogID: uuid.NewString(), - done: make(chan struct{}), - } - - // Check if snapshot request + // Snapshot Flow if isSnapshot { - client.prod = NewSnapshotProducer(ringAPI, cameraID) + 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 } @@ -186,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 } @@ -202,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) { @@ -230,8 +145,8 @@ 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 } @@ -242,13 +157,16 @@ func Dial(rawURL string) (*Client, error) { 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{ { @@ -290,188 +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 int(doorbotID) != c.cameraID { - 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 - } - - prod.SDP = msg.Body.SDP - } - - 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.cameraID - 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() } @@ -487,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) } @@ -500,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 727a5245..b52eadac 100644 --- a/pkg/ring/snapshot.go +++ b/pkg/ring/snapshot.go @@ -10,11 +10,11 @@ import ( type SnapshotProducer struct { core.Connection - client *RingRestClient - cameraID string + client *RingApi + cameraID int } -func NewSnapshotProducer(client *RingRestClient, cameraID string) *SnapshotProducer { +func NewSnapshotProducer(client *RingApi, cameraID int) *SnapshotProducer { return &SnapshotProducer{ Connection: core.Connection{ ID: core.NewID(), @@ -41,8 +41,7 @@ func NewSnapshotProducer(client *RingRestClient, cameraID string) *SnapshotProdu } func (p *SnapshotProducer) Start() error { - // Fetch snapshot - response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%s", p.cameraID), 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); } From 79656d13447dfcd6cc1fe6a2da589243586751f8 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 26 May 2025 23:10:55 +0200 Subject: [PATCH 09/41] update readme --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 90a2537f..8a1e020a 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg * [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) @@ -220,6 +221,7 @@ Supported for 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)). @@ -642,6 +644,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)* From dfc1f45f974f8b182b1f9574dc8c10d2e0817cab Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 2 Jun 2025 22:06:47 +0300 Subject: [PATCH 10/41] support preloading streams --- internal/streams/preload.go | 30 ++++++++++++ internal/streams/streams.go | 17 +++++-- pkg/preload/producer.go | 92 +++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 internal/streams/preload.go create mode 100644 pkg/preload/producer.go diff --git a/internal/streams/preload.go b/internal/streams/preload.go new file mode 100644 index 00000000..c811cc5c --- /dev/null +++ b/internal/streams/preload.go @@ -0,0 +1,30 @@ +package streams + +import ( + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/preload" +) + +func (s *Stream) Preload(query url.Values) error { + cons := preload.NewPreload(query) + + if err := s.AddConsumer(cons); err != nil { + return err + } + + return nil +} + +func Preload(src string) { + name, rawQuery, _ := strings.Cut(src, "#") + query := ParseQuery(rawQuery) + + if stream := Get(name); stream != nil { + if err := stream.Preload(query); err != nil { + log.Error().Err(err).Caller().Send() + } + return + } +} diff --git a/internal/streams/streams.go b/internal/streams/streams.go index dcbaba28..7bbccace 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -16,6 +16,7 @@ func Init() { var cfg struct { Streams map[string]any `yaml:"streams"` Publish map[string]any `yaml:"publish"` + Preload []string `yaml:"preload"` } app.LoadConfig(&cfg) @@ -29,14 +30,22 @@ func Init() { api.HandleFunc("api/streams", apiStreams) api.HandleFunc("api/streams.dot", apiStreamsDOT) - if cfg.Publish == nil { + if cfg.Publish == nil && cfg.Preload == nil { return } time.AfterFunc(time.Second, func() { - for name, dst := range cfg.Publish { - if stream := Get(name); stream != nil { - Publish(stream, dst) + if cfg.Publish != nil { + for name, dst := range cfg.Publish { + if stream := Get(name); stream != nil { + Publish(stream, dst) + } + } + } + + if cfg.Preload != nil { + for _, src := range cfg.Preload { + Preload(src) } } }) diff --git a/pkg/preload/producer.go b/pkg/preload/producer.go new file mode 100644 index 00000000..811cf2e4 --- /dev/null +++ b/pkg/preload/producer.go @@ -0,0 +1,92 @@ +package preload + +import ( + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Preload struct { + core.Connection + + Closed core.Waiter +} + +func NewPreload(query url.Values) *Preload { + medias := core.ParseQuery(query) + + for _, value := range query["microphone"] { + media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionRecvonly} + + for _, name := range strings.Split(value, ",") { + name = strings.ToUpper(name) + switch name { + case "", "COPY": + name = core.CodecAny + } + media.Codecs = append(media.Codecs, &core.Codec{Name: name}) + } + + medias = append(medias, media) + } + + if len(medias) == 0 { + medias = []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{{Name: core.CodecAny}}, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{{Name: core.CodecAny}}, + }, + } + } + + return &Preload{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "preload", + Medias: medias, + Protocol: "native", + RemoteAddr: "localhost", + UserAgent: "go2rtc", + }, + } +} + +func (p *Preload) GetMedias() []*core.Media { + return p.Medias +} + +func (p *Preload) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + sender.Bind(track) + p.Senders = append(p.Senders, sender) + return nil +} + +func (p *Preload) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + receiver := core.NewReceiver(media, codec) + p.Receivers = append(p.Receivers, receiver) + return receiver, nil +} + +func (p *Preload) Start() error { + p.Closed.Wait() + return nil +} + +func (p *Preload) Stop() error { + for _, receiver := range p.Receivers { + receiver.Close() + } + for _, sender := range p.Senders { + sender.Close() + } + p.Closed.Done(nil) + return nil +} From 020549ef60103724575aee52e20096e817cb694b Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 2 Jun 2025 22:16:43 +0300 Subject: [PATCH 11/41] readme --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 90a2537f..09cafaeb 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg * [Incoming sources](#incoming-sources) * [Stream to camera](#stream-to-camera) * [Publish stream](#publish-stream) + * [Preload stream](#preload-stream) * [Module: API](#module-api) * [Module: RTSP](#module-rtsp) * [Module: RTMP](#module-rtmp) @@ -818,6 +819,25 @@ streams: - **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming. - **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key. +### Preload stream + +You can preload any stream on go2rtc start. This is useful for cameras that take a long time to start up. + +```yaml +preload: + - my_stream1 + - my_stream2#video#audio#microphone + - my_stream3#video=h265#audio=opus +streams: + my_stream1: + - rtsp://129.168.3.1:554/stream1 + my_stream2: + - rtsp://129.168.3.1:554/stream1 + my_stream3: + - rtsp://129.168.3.1:554/stream1 + - ffmpeg:my_stream3#video=copy#audio=opus +```` + ### Module: API The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`. From 493fa1ef6a1478e39c6a18f075c1434e43007242 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 5 Jun 2025 11:33:03 +0300 Subject: [PATCH 12/41] add api endpoints and change config syntax --- internal/streams/api.go | 62 +++++++++++++++++++++++++++++++++++++ internal/streams/preload.go | 26 +++++++++------- internal/streams/streams.go | 11 ++++--- pkg/preload/producer.go | 6 ++-- 4 files changed, 85 insertions(+), 20 deletions(-) diff --git a/internal/streams/api.go b/internal/streams/api.go index 061e61c2..c0c6744b 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -122,3 +122,65 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { api.Response(w, dot, "text/vnd.graphviz") } + +func apiPreload(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + src := query.Get("src") + query.Del("src") + + videoQuery := query.Get("video") + audioQuery := query.Get("audio") + micQuery := query.Get("microphone") + + if src == "" { + http.Error(w, "no source", http.StatusBadRequest) + return + } + + switch r.Method { + case "PUT": + // check if stream exists + if stream := Get(src); stream == nil { + http.Error(w, "stream not found", http.StatusNotFound) + return + } + + // check if consumer exists + if cons, ok := preloads[src]; ok { + cons.Stop() + delete(preloads, src) + } + + var rawQuery string + if videoQuery != "" { + rawQuery += "video=" + videoQuery + "#" + } + if audioQuery != "" { + rawQuery += "audio=" + audioQuery + "#" + } + if micQuery != "" { + rawQuery += "microphone=" + micQuery + } + + if err := app.PatchConfig([]string{"preload", src}, rawQuery); err != nil { + log.Error().Err(err).Str("src", src).Msg("Failed to patch config for PUT") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + Preload(src, rawQuery) + + case "DELETE": + if cons, ok := preloads[src]; ok { + cons.Stop() + delete(preloads, src) + } + + if err := app.PatchConfig([]string{"preload", src}, nil); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + default: + http.Error(w, "", http.StatusMethodNotAllowed) + } +} diff --git a/internal/streams/preload.go b/internal/streams/preload.go index c811cc5c..7314df55 100644 --- a/internal/streams/preload.go +++ b/internal/streams/preload.go @@ -2,13 +2,15 @@ package streams import ( "net/url" - "strings" "github.com/AlexxIT/go2rtc/pkg/preload" ) -func (s *Stream) Preload(query url.Values) error { - cons := preload.NewPreload(query) +var preloads = map[string]*preload.Preload{} + +func (s *Stream) Preload(name string, query url.Values) error { + cons := preload.NewPreload(name, query) + preloads[name] = cons if err := s.AddConsumer(cons); err != nil { return err @@ -17,14 +19,16 @@ func (s *Stream) Preload(query url.Values) error { return nil } -func Preload(src string) { - name, rawQuery, _ := strings.Cut(src, "#") - query := ParseQuery(rawQuery) - - if stream := Get(name); stream != nil { - if err := stream.Preload(query); err != nil { - log.Error().Err(err).Caller().Send() - } +func Preload(src string, rawQuery string) { + // skip if exists + if _, ok := preloads[src]; ok { return } + + if stream := Get(src); stream != nil { + query := ParseQuery(rawQuery) + if err := stream.Preload(src, query); err != nil { + log.Error().Err(err).Caller().Send() + } + } } diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 7bbccace..8f07ea12 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -14,9 +14,9 @@ import ( func Init() { var cfg struct { - Streams map[string]any `yaml:"streams"` - Publish map[string]any `yaml:"publish"` - Preload []string `yaml:"preload"` + Streams map[string]any `yaml:"streams"` + Publish map[string]any `yaml:"publish"` + Preload map[string]string `yaml:"preload"` } app.LoadConfig(&cfg) @@ -29,6 +29,7 @@ func Init() { api.HandleFunc("api/streams", apiStreams) api.HandleFunc("api/streams.dot", apiStreamsDOT) + api.HandleFunc("api/preload", apiPreload) if cfg.Publish == nil && cfg.Preload == nil { return @@ -44,8 +45,8 @@ func Init() { } if cfg.Preload != nil { - for _, src := range cfg.Preload { - Preload(src) + for name, rawQuery := range cfg.Preload { + Preload(name, rawQuery) } } }) diff --git a/pkg/preload/producer.go b/pkg/preload/producer.go index 811cf2e4..05a50d52 100644 --- a/pkg/preload/producer.go +++ b/pkg/preload/producer.go @@ -9,11 +9,10 @@ import ( type Preload struct { core.Connection - Closed core.Waiter } -func NewPreload(query url.Values) *Preload { +func NewPreload(name string, query url.Values) *Preload { medias := core.ParseQuery(query) for _, value := range query["microphone"] { @@ -49,11 +48,10 @@ func NewPreload(query url.Values) *Preload { return &Preload{ Connection: core.Connection{ ID: core.NewID(), - FormatName: "preload", Medias: medias, Protocol: "native", RemoteAddr: "localhost", - UserAgent: "go2rtc", + UserAgent: "go2rtc/preload", }, } } From 8ab7aeb8b25995199980e2411793b302ee9ab126 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 5 Jun 2025 15:51:14 +0300 Subject: [PATCH 13/41] update readme --- README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 09cafaeb..627d9bb9 100644 --- a/README.md +++ b/README.md @@ -825,18 +825,19 @@ You can preload any stream on go2rtc start. This is useful for cameras that take ```yaml preload: - - my_stream1 - - my_stream2#video#audio#microphone - - my_stream3#video=h265#audio=opus + camera1: # default: video&audio = ANY + camera2: "video" # preload only video track + camera3: "video=h264#audio=opus" # initialize transcoding pipeline + streams: - my_stream1: - - rtsp://129.168.3.1:554/stream1 - my_stream2: - - rtsp://129.168.3.1:554/stream1 - my_stream3: - - rtsp://129.168.3.1:554/stream1 - - ffmpeg:my_stream3#video=copy#audio=opus -```` + camera1: + - rtsp://192.168.1.100/stream + camera2: + - rtsp://192.168.1.101/stream + camera3: + - rtsp://192.168.1.102/h265stream + - ffmpeg:camera3#video=h264#audio=opus#hardware +``` ### Module: API From 91eeefec68405539d6d15996dbbb4d0d951b82fb Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 5 Jun 2025 16:01:49 +0300 Subject: [PATCH 14/41] openapi: add preload endpoints --- api/openapi.yaml | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/api/openapi.yaml b/api/openapi.yaml index 618acb48..a2d66a87 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -237,6 +237,54 @@ paths: + /api/preload: + put: + summary: Preload new stream + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream source (name) + required: true + schema: { type: string } + example: "camera1" + - name: video + in: query + description: Video codecs filter + required: false + schema: { type: string } + example: all,h264,h265,... + - name: audio + in: query + description: Audio codecs filter + required: false + schema: { type: string } + example: all,aac,opus,... + - name: microphone + in: query + description: Microphone codecs filter + required: false + schema: { type: string } + example: all,aac,opus,... + responses: + default: + description: Default response + delete: + summary: Delete preloaded stream + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream source (name) + required: true + schema: { type: string } + example: "camera1" + responses: + default: + description: Default response + + + /api/streams?src={src}: get: summary: Get stream info in JSON format From b6579122d1037934bccf33e1007a15e76bd2f166 Mon Sep 17 00:00:00 2001 From: seydx Date: Fri, 6 Jun 2025 03:11:28 +0200 Subject: [PATCH 15/41] fix --- internal/streams/api.go | 12 +++++++++--- pkg/preload/producer.go | 6 +++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/internal/streams/api.go b/internal/streams/api.go index c0c6744b..47febeb4 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -140,14 +140,15 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { switch r.Method { case "PUT": // check if stream exists - if stream := Get(src); stream == nil { + stream := Get(src) + if stream == nil { http.Error(w, "stream not found", http.StatusNotFound) return } // check if consumer exists if cons, ok := preloads[src]; ok { - cons.Stop() + stream.RemoveConsumer(cons) delete(preloads, src) } @@ -172,7 +173,12 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { case "DELETE": if cons, ok := preloads[src]; ok { - cons.Stop() + if stream := Get(src); stream != nil { + stream.RemoveConsumer(cons) + } else { + cons.Stop() + } + delete(preloads, src) } diff --git a/pkg/preload/producer.go b/pkg/preload/producer.go index 05a50d52..932f5e29 100644 --- a/pkg/preload/producer.go +++ b/pkg/preload/producer.go @@ -9,7 +9,7 @@ import ( type Preload struct { core.Connection - Closed core.Waiter + closed core.Waiter } func NewPreload(name string, query url.Values) *Preload { @@ -74,7 +74,7 @@ func (p *Preload) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver } func (p *Preload) Start() error { - p.Closed.Wait() + p.closed.Wait() return nil } @@ -85,6 +85,6 @@ func (p *Preload) Stop() error { for _, sender := range p.Senders { sender.Close() } - p.Closed.Done(nil) + p.closed.Done(nil) return nil } From 6732e726d51b8433a413f81973e6ccd4a9c2848b Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 16 Jun 2025 00:33:16 +0200 Subject: [PATCH 16/41] update preload consumer to handle RTP packets --- pkg/preload/producer.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/preload/producer.go b/pkg/preload/producer.go index 932f5e29..8eb6aec2 100644 --- a/pkg/preload/producer.go +++ b/pkg/preload/producer.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" ) type Preload struct { @@ -62,7 +63,10 @@ func (p *Preload) GetMedias() []*core.Media { func (p *Preload) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { sender := core.NewSender(media, track.Codec) - sender.Bind(track) + sender.Handler = func(pkt *rtp.Packet) { + p.Send += pkt.MarshalSize() + } + sender.HandleRTP(track) p.Senders = append(p.Senders, sender) return nil } From 57714544004b19da46771baccc0c6296bde8ae51 Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 16 Jun 2025 00:50:48 +0200 Subject: [PATCH 17/41] use preload as format name --- pkg/preload/producer.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/preload/producer.go b/pkg/preload/producer.go index 8eb6aec2..748dff16 100644 --- a/pkg/preload/producer.go +++ b/pkg/preload/producer.go @@ -49,10 +49,9 @@ func NewPreload(name string, query url.Values) *Preload { return &Preload{ Connection: core.Connection{ ID: core.NewID(), + FormatName: "preload", Medias: medias, - Protocol: "native", RemoteAddr: "localhost", - UserAgent: "go2rtc/preload", }, } } From ef318f663e854d3ba5e8af3641947edfec43df4d Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 16 Jun 2025 09:32:07 +0200 Subject: [PATCH 18/41] fix preload queries --- internal/streams/api.go | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/internal/streams/api.go b/internal/streams/api.go index 47febeb4..1b91f906 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -128,10 +128,6 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { src := query.Get("src") query.Del("src") - videoQuery := query.Get("video") - audioQuery := query.Get("audio") - micQuery := query.Get("microphone") - if src == "" { http.Error(w, "no source", http.StatusBadRequest) return @@ -152,15 +148,28 @@ func apiPreload(w http.ResponseWriter, r *http.Request) { delete(preloads, src) } + // parse query parameters var rawQuery string - if videoQuery != "" { - rawQuery += "video=" + videoQuery + "#" + if query.Has("video") { + if videoQuery := query.Get("video"); videoQuery != "" { + rawQuery += "video=" + videoQuery + "#" + } else { + rawQuery += "video#" + } } - if audioQuery != "" { - rawQuery += "audio=" + audioQuery + "#" + if query.Has("audio") { + if audioQuery := query.Get("audio"); audioQuery != "" { + rawQuery += "audio=" + audioQuery + "#" + } else { + rawQuery += "audio#" + } } - if micQuery != "" { - rawQuery += "microphone=" + micQuery + if query.Has("microphone") { + if micQuery := query.Get("microphone"); micQuery != "" { + rawQuery += "microphone=" + micQuery + "#" + } else { + rawQuery += "microphone#" + } } if err := app.PatchConfig([]string{"preload", src}, rawQuery); err != nil { From 647b2acf487a36b9d478c8659a6c5f43880f840a Mon Sep 17 00:00:00 2001 From: seydx Date: Mon, 16 Jun 2025 09:58:55 +0200 Subject: [PATCH 19/41] cleanup --- pkg/preload/{producer.go => consumer.go} | 11 ----------- 1 file changed, 11 deletions(-) rename pkg/preload/{producer.go => consumer.go} (84%) diff --git a/pkg/preload/producer.go b/pkg/preload/consumer.go similarity index 84% rename from pkg/preload/producer.go rename to pkg/preload/consumer.go index 748dff16..4d3735a8 100644 --- a/pkg/preload/producer.go +++ b/pkg/preload/consumer.go @@ -51,15 +51,10 @@ func NewPreload(name string, query url.Values) *Preload { ID: core.NewID(), FormatName: "preload", Medias: medias, - RemoteAddr: "localhost", }, } } -func (p *Preload) GetMedias() []*core.Media { - return p.Medias -} - func (p *Preload) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { sender := core.NewSender(media, track.Codec) sender.Handler = func(pkt *rtp.Packet) { @@ -70,12 +65,6 @@ func (p *Preload) AddTrack(media *core.Media, codec *core.Codec, track *core.Rec return nil } -func (p *Preload) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - receiver := core.NewReceiver(media, codec) - p.Receivers = append(p.Receivers, receiver) - return receiver, nil -} - func (p *Preload) Start() error { p.closed.Wait() return nil From 7bb0f0d2e660979be9e5a0693a1911cb17948ec1 Mon Sep 17 00:00:00 2001 From: seydx Date: Thu, 19 Jun 2025 10:29:55 +0200 Subject: [PATCH 20/41] readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8a1e020a..af9a99f9 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,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 From 34b103bbcba512a07fb1ea8a3c219ae31d2201bf Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 8 Jul 2025 12:43:14 +0300 Subject: [PATCH 21/41] Update all dependencies and min go version to 1.23 --- .github/workflows/build.yml | 4 ++-- go.mod | 38 ++++++++++++++++++------------------- go.sum | 36 +++++++++++++++++++++++++++++++++++ scripts/README.md | 2 ++ scripts/build.cmd | 5 ----- 5 files changed, 59 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7950004d..ac4d758d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,7 +29,7 @@ jobs: with: { name: go2rtc_win64, path: go2rtc.exe } - name: Build go2rtc_win32 - env: { GOOS: windows, GOARCH: 386, GOTOOLCHAIN: go1.20.14 } + env: { GOOS: windows, GOARCH: 386 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_win32 uses: actions/upload-artifact@v4 @@ -85,7 +85,7 @@ jobs: with: { name: go2rtc_linux_mipsel, path: go2rtc } - name: Build go2rtc_mac_amd64 - env: { GOOS: darwin, GOARCH: amd64, GOTOOLCHAIN: go1.20.14 } + env: { GOOS: darwin, GOARCH: amd64 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_mac_amd64 uses: actions/upload-artifact@v4 diff --git a/go.mod b/go.mod index 997737cf..7abf1edd 100644 --- a/go.mod +++ b/go.mod @@ -1,49 +1,49 @@ module github.com/AlexxIT/go2rtc -go 1.20 +go 1.23.0 require ( github.com/asticode/go-astits v1.13.0 - github.com/expr-lang/expr v1.17.2 + github.com/expr-lang/expr v1.17.5 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/mattn/go-isatty v0.0.20 - github.com/miekg/dns v1.1.63 - github.com/pion/ice/v4 v4.0.9 - github.com/pion/interceptor v0.1.37 + github.com/miekg/dns v1.1.66 + github.com/pion/ice/v4 v4.0.10 + github.com/pion/interceptor v0.1.40 github.com/pion/rtcp v1.2.15 - github.com/pion/rtp v1.8.13 - github.com/pion/sdp/v3 v3.0.11 - github.com/pion/srtp/v3 v3.0.4 + github.com/pion/rtp v1.8.20 + github.com/pion/sdp/v3 v3.0.14 + github.com/pion/srtp/v3 v3.0.6 github.com/pion/stun/v3 v3.0.0 - github.com/pion/webrtc/v4 v4.0.14 + github.com/pion/webrtc/v4 v4.1.3 github.com/rs/zerolog v1.34.0 github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f github.com/stretchr/testify v1.10.0 github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 - golang.org/x/crypto v0.33.0 + golang.org/x/crypto v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/asticode/go-astikit v0.54.0 // indirect + github.com/asticode/go-astikit v0.56.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v3 v3.0.6 // indirect - github.com/pion/logging v0.2.3 // indirect + github.com/pion/logging v0.2.4 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/sctp v1.8.37 // indirect + github.com/pion/sctp v1.8.39 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v4 v4.0.0 // indirect + github.com/pion/turn/v4 v4.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.20.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/tools v0.24.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/tools v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index c5a92c73..7e1b0cee 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= github.com/asticode/go-astikit v0.54.0 h1:uq9eurgisdkYwJU9vSWIQaPH4MH0cac82sQH00kmSNQ= github.com/asticode/go-astikit v0.54.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= +github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw= +github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c= github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -10,6 +12,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/expr-lang/expr v1.17.2 h1:o0A99O/Px+/DTjEnQiodAgOIK9PPxL8DtXhBRKC+Iso= github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= +github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -28,16 +32,24 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= +github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= +github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= github.com/pion/ice/v4 v4.0.9 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk= github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= +github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= +github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= @@ -46,20 +58,32 @@ github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= github.com/pion/rtp v1.8.13 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg= github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= +github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI= +github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= +github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI= github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI= +github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= +github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= +github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= +github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= +github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= github.com/pion/webrtc/v4 v4.0.14 h1:nyds/sFRR+HvmWoBa6wrL46sSfpArE0qR883MBW96lg= github.com/pion/webrtc/v4 v4.0.14/go.mod h1:R3+qTnQTS03UzwDarYecgioNf7DYgTsldxnCXB821Kk= +github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c= +github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= @@ -84,19 +108,31 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/scripts/README.md b/scripts/README.md index 669fe2b2..9c7f4544 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,6 +16,8 @@ golang.org/x/sys v0.30.0 // indirect golang.org/x/tools v0.24.0 // indirect ``` +**PS.** Unfortunately, due to the dependency on `pion/webrtc/v4 v4.1.3`, had to upgrade go to `1.23`. + ## Build - UPX-3.96 pack broken bin for `linux_mipsel` diff --git a/scripts/build.cmd b/scripts/build.cmd index a543ea80..37ccd441 100644 --- a/scripts/build.cmd +++ b/scripts/build.cmd @@ -1,18 +1,15 @@ @ECHO OFF -@SET GOTOOLCHAIN= @SET GOOS=windows @SET GOARCH=amd64 @SET FILENAME=go2rtc_win64.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe -@SET GOTOOLCHAIN=go1.20.14 @SET GOOS=windows @SET GOARCH=386 @SET FILENAME=go2rtc_win32.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe -@SET GOTOOLCHAIN= @SET GOOS=windows @SET GOARCH=arm64 @SET FILENAME=go2rtc_win_arm64.zip @@ -50,13 +47,11 @@ go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME @SET FILENAME=go2rtc_linux_mipsel go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% -@SET GOTOOLCHAIN=go1.20.14 @SET GOOS=darwin @SET GOARCH=amd64 @SET FILENAME=go2rtc_mac_amd64.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc -@SET GOTOOLCHAIN= @SET GOOS=darwin @SET GOARCH=arm64 @SET FILENAME=go2rtc_mac_arm64.zip From 4577390130b351e56dd0d031ef3a7effd04d82cc Mon Sep 17 00:00:00 2001 From: Hugo Aboud Date: Tue, 19 Aug 2025 16:16:01 -0300 Subject: [PATCH 22/41] Sanitize credentials on websocket error messages --- internal/api/ws/ws.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/api/ws/ws.go b/internal/api/ws/ws.go index 1d945bfe..5a7d34be 100644 --- a/internal/api/ws/ws.go +++ b/internal/api/ws/ws.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "time" + "regexp" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" @@ -132,7 +133,11 @@ func apiWS(w http.ResponseWriter, r *http.Request) { if handler := wsHandlers[msg.Type]; handler != nil { go func() { if err = handler(tr, msg); err != nil { - tr.Write(&Message{Type: "error", Value: msg.Type + ": " + err.Error()}) + // Some streams such as ffmpeg might return credentials on error messages + errMsg := err.Error() + sanitizer := regexp.MustCompile(`(\w+)://(.*)@`) + errMsg = sanitizer.ReplaceAllString(errMsg, "$1://******@") + tr.Write(&Message{Type: "error", Value: msg.Type + ": " + errMsg}) } }() } From 8f7cbdf60a6227d90a8e3a7bd57b484bb0bf0d73 Mon Sep 17 00:00:00 2001 From: Ragnar Petursson <74888286+kvikindi@users.noreply.github.com> Date: Sat, 23 Aug 2025 18:09:28 +0100 Subject: [PATCH 23/41] Update Proxmox Helper Scripts link in README.md Changed link to current link, as previous repository is inactive as of November 2nd 2024. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9712bbde..12342a03 100644 --- a/README.md +++ b/README.md @@ -1391,7 +1391,7 @@ streams: - [Arch User Repository](https://linux-packages.com/aur/package/go2rtc) - [Gentoo](https://github.com/inode64/inode64-overlay/tree/main/media-video/go2rtc) - [NixOS](https://search.nixos.org/packages?query=go2rtc) -- [Proxmox Helper Scripts](https://tteck.github.io/Proxmox/) +- [Proxmox Helper Scripts](https://github.com/community-scripts/ProxmoxVE/) - [QNAP](https://www.myqnap.org/product/go2rtc/) - [Synology NAS](https://synocommunity.com/package/go2rtc) - [Unraid](https://unraid.net/community/apps?q=go2rtc) From 33f0fd5fe6b6d1baf0fe742ce7ffa237a58dfddf Mon Sep 17 00:00:00 2001 From: Alex X Date: Thu, 10 Jul 2025 16:47:52 +0300 Subject: [PATCH 24/41] Add lightNVR project to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 12342a03..13943770 100644 --- a/README.md +++ b/README.md @@ -1384,6 +1384,7 @@ streams: - [ioBroker.euSec](https://github.com/bropat/ioBroker.eusec) - [ioBroker](https://www.iobroker.net/) adapter for controlling Eufy security devices - [MMM-go2rtc](https://github.com/Anonym-tsk/MMM-go2rtc) - MagicMirror² module - [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring-to-MQTT bridge +- [lightNVR](https://github.com/opensensor/lightNVR) **Distributions** From beb82045ffd31dca4ef0046cf7c7da05f0fa19a1 Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 29 Aug 2025 16:45:19 +0300 Subject: [PATCH 25/41] Fix yet another broken Content-Base for RTSP #1852 --- pkg/rtsp/helpers.go | 27 +++++++++++++++++++++++---- pkg/rtsp/rtsp_test.go | 10 ++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/pkg/rtsp/helpers.go b/pkg/rtsp/helpers.go index d8ed1685..c73bd0a2 100644 --- a/pkg/rtsp/helpers.go +++ b/pkg/rtsp/helpers.go @@ -116,20 +116,39 @@ func findFmtpLine(payloadType uint8, descriptions []*sdp.MediaDescription) strin // urlParse fix bugs: // 1. Content-Base: rtsp://::ffff:192.168.1.123/onvif/profile.1/ // 2. Content-Base: rtsp://rtsp://turret2-cam.lan:554/stream1/ +// 3. Content-Base: 192.168.253.220:1935/ func urlParse(rawURL string) (*url.URL, error) { // fix https://github.com/AlexxIT/go2rtc/issues/830 if strings.HasPrefix(rawURL, "rtsp://rtsp://") { rawURL = rawURL[7:] } + // fix https://github.com/AlexxIT/go2rtc/issues/1852 + if !strings.Contains(rawURL, "://") { + rawURL = "rtsp://" + rawURL + } + u, err := url.Parse(rawURL) if err != nil && strings.HasSuffix(err.Error(), "after host") { - if i1 := strings.Index(rawURL, "://"); i1 > 0 { - if i2 := strings.IndexByte(rawURL[i1+3:], '/'); i2 > 0 { - return urlParse(rawURL[:i1+3+i2] + ":" + rawURL[i1+3+i2:]) - } + if i := indexN(rawURL, '/', 3); i > 0 { + return urlParse(rawURL[:i] + ":" + rawURL[i:]) } } return u, err } + +func indexN(s string, c byte, n int) int { + var offset int + for { + i := strings.IndexByte(s[offset:], c) + if i < 0 { + break + } + if n--; n == 0 { + return offset + i + } + offset += i + 1 + } + return -1 +} diff --git a/pkg/rtsp/rtsp_test.go b/pkg/rtsp/rtsp_test.go index 14c99803..282c04f8 100644 --- a/pkg/rtsp/rtsp_test.go +++ b/pkg/rtsp/rtsp_test.go @@ -11,14 +11,20 @@ func TestURLParse(t *testing.T) { // https://github.com/AlexxIT/WebRTC/issues/395 base := "rtsp://::ffff:192.168.1.123/onvif/profile.1/" u, err := urlParse(base) - assert.Empty(t, err) + assert.NoError(t, err) assert.Equal(t, "::ffff:192.168.1.123:", u.Host) // https://github.com/AlexxIT/go2rtc/issues/208 base = "rtsp://rtsp://turret2-cam.lan:554/stream1/" u, err = urlParse(base) - assert.Empty(t, err) + assert.NoError(t, err) assert.Equal(t, "turret2-cam.lan:554", u.Host) + + // https://github.com/AlexxIT/go2rtc/issues/1852 + base = "192.168.253.220:1935/" + u, err = urlParse(base) + assert.NoError(t, err) + assert.Equal(t, "192.168.253.220:1935", u.Host) } func TestBugSDP1(t *testing.T) { From cd7fa5d09cc020d6ce697f619d02c91eaf146ac1 Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 10 Sep 2025 12:27:13 +0300 Subject: [PATCH 26/41] Fix RepairAVCC in some cases --- pkg/h264/avcc.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/h264/avcc.go b/pkg/h264/avcc.go index d21e3ea3..dd3a5687 100644 --- a/pkg/h264/avcc.go +++ b/pkg/h264/avcc.go @@ -16,6 +16,11 @@ func RepairAVCC(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { ps := JoinNALU(sps, pps) return func(packet *rtp.Packet) { + // this can happen for FLV from FFmpeg + if NALUType(packet.Payload) == NALUTypeSEI { + size := int(binary.BigEndian.Uint32(packet.Payload)) + 4 + packet.Payload = packet.Payload[size:] + } if NALUType(packet.Payload) == NALUTypeIFrame { packet.Payload = Join(ps, packet.Payload) } From 788afb7189979db3533cae1f7a1ed0c3c859121d Mon Sep 17 00:00:00 2001 From: Alex X Date: Thu, 18 Sep 2025 23:08:33 +0300 Subject: [PATCH 27/41] Fix HomeKit server support on iOS 26 #1843 --- pkg/hap/camera/accessory.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/hap/camera/accessory.go b/pkg/hap/camera/accessory.go index 42037d96..973983ec 100644 --- a/pkg/hap/camera/accessory.go +++ b/pkg/hap/camera/accessory.go @@ -12,7 +12,7 @@ func NewAccessory(manuf, model, name, serial, firmware string) *hap.Accessory { hap.ServiceAccessoryInformation(manuf, model, name, serial, firmware), ServiceCameraRTPStreamManagement(), //hap.ServiceHAPProtocolInformation(), - //ServiceMicrophone(), + ServiceMicrophone(), }, } acc.InitIID() @@ -30,17 +30,17 @@ func ServiceMicrophone() *hap.Service { Perms: hap.EVPRPW, //Descr: "Mute", }, - { - Type: "119", - Format: hap.FormatUInt8, - Value: 100, - Perms: hap.EVPRPW, - //Descr: "Volume", - //Unit: hap.UnitPercentage, - //MinValue: 0, - //MaxValue: 100, - //MinStep: 1, - }, + //{ + // Type: "119", + // Format: hap.FormatUInt8, + // Value: 100, + // Perms: hap.EVPRPW, + // //Descr: "Volume", + // //Unit: hap.UnitPercentage, + // //MinValue: 0, + // //MaxValue: 100, + // //MinStep: 1, + //}, }, } } @@ -62,7 +62,7 @@ func ServiceCameraRTPStreamManagement() *hap.Service { VideoAttrs: []VideoAttrs{ {Width: 1920, Height: 1080, Framerate: 30}, {Width: 1280, Height: 720, Framerate: 30}, // important for iPhones - {Width: 320, Height: 240, Framerate: 15}, // apple watch + {Width: 320, Height: 240, Framerate: 15}, // apple watch }, }, }, From 8b4df5f02c7882256799f8c769e5c1e999d8bd6b Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 19 Sep 2025 15:21:02 +0300 Subject: [PATCH 28/41] Code refactoring for #1841 --- internal/api/ws/ws.go | 7 ++----- pkg/core/core_test.go | 14 ++++++++++++++ pkg/core/helpers.go | 12 ++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/internal/api/ws/ws.go b/internal/api/ws/ws.go index 5a7d34be..981d1b41 100644 --- a/internal/api/ws/ws.go +++ b/internal/api/ws/ws.go @@ -8,10 +8,10 @@ import ( "strings" "sync" "time" - "regexp" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/gorilla/websocket" "github.com/rs/zerolog" ) @@ -133,10 +133,7 @@ func apiWS(w http.ResponseWriter, r *http.Request) { if handler := wsHandlers[msg.Type]; handler != nil { go func() { if err = handler(tr, msg); err != nil { - // Some streams such as ffmpeg might return credentials on error messages - errMsg := err.Error() - sanitizer := regexp.MustCompile(`(\w+)://(.*)@`) - errMsg = sanitizer.ReplaceAllString(errMsg, "$1://******@") + errMsg := core.StripUserinfo(err.Error()) tr.Write(&Message{Type: "error", Value: msg.Type + ": " + errMsg}) } }() diff --git a/pkg/core/core_test.go b/pkg/core/core_test.go index 4a05380a..e7845ca7 100644 --- a/pkg/core/core_test.go +++ b/pkg/core/core_test.go @@ -118,3 +118,17 @@ func TestName(t *testing.T) { // stage3 _ = prod2.Stop() } + +func TestStripUserinfo(t *testing.T) { + s := `streams: + test: + - ffmpeg:rtsp://username:password@10.1.2.3:554/stream1 + - ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy +` + s = StripUserinfo(s) + require.Equal(t, `streams: + test: + - ffmpeg:rtsp://***@10.1.2.3:554/stream1 + - ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy +`, s) +} diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index 72afe897..161a5504 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -2,6 +2,7 @@ package core import ( "crypto/rand" + "regexp" "runtime" "strconv" "strings" @@ -77,3 +78,14 @@ func Caller() string { _, file, line, _ := runtime.Caller(1) return file + ":" + strconv.Itoa(line) } + +const ( + unreserved = `A-Za-z0-9-._~` + subdelims = `!$&'()*+,;=` + userinfo = unreserved + subdelims + `%:` +) + +func StripUserinfo(s string) string { + sanitizer := regexp.MustCompile(`://[` + userinfo + `]+@`) + return sanitizer.ReplaceAllString(s, `://***@`) +} From 45cbbaf1cfa399d79081d7a55708b0019ac77dc1 Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 19 Sep 2025 15:26:54 +0300 Subject: [PATCH 29/41] Fixed a race condition when changing the config file --- internal/app/config.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/app/config.go b/internal/app/config.go index 9d4480b7..f0eb36e0 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/AlexxIT/go2rtc/pkg/yaml" @@ -18,11 +19,16 @@ func LoadConfig(v any) { } } +var configMu sync.Mutex + func PatchConfig(path []string, value any) error { if ConfigPath == "" { return errors.New("config file disabled") } + configMu.Lock() + defer configMu.Unlock() + // empty config is OK b, _ := os.ReadFile(ConfigPath) From 40269328fb28eb79413a097e4f3e94b051ff477c Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 19 Sep 2025 15:27:58 +0300 Subject: [PATCH 30/41] Fix insecure PINs for HomeKit server --- pkg/hap/helpers.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/hap/helpers.go b/pkg/hap/helpers.go index d1400b84..3900f935 100644 --- a/pkg/hap/helpers.go +++ b/pkg/hap/helpers.go @@ -71,11 +71,17 @@ type JSONCharacter struct { Event any `json:"ev,omitempty"` } +// 4.2.1.2 Invalid Setup Codes +const insecurePINs = "00000000 11111111 22222222 33333333 44444444 55555555 66666666 77777777 88888888 99999999 12345678 87654321" + func SanitizePin(pin string) (string, error) { s := strings.ReplaceAll(pin, "-", "") if len(s) != 8 { return "", errors.New("hap: wrong PIN format: " + pin) } + if strings.Contains(insecurePINs, s) { + return "", errors.New("hap: insecure PIN: " + pin) + } // 123-45-678 return s[:3] + "-" + s[3:5] + "-" + s[5:], nil } From 3b976c68122228e84fbf41c4c280edbe3e10ecbb Mon Sep 17 00:00:00 2001 From: Alex X Date: Fri, 19 Sep 2025 15:29:24 +0300 Subject: [PATCH 31/41] Improve HomeKit TLV format parser --- pkg/hap/tlv8/tlv8.go | 131 +++++++++++++++++++++++++------------- pkg/hap/tlv8/tlv8_test.go | 47 ++++++++++++++ 2 files changed, 133 insertions(+), 45 deletions(-) diff --git a/pkg/hap/tlv8/tlv8.go b/pkg/hap/tlv8/tlv8.go index 068f21c3..7af27ea4 100644 --- a/pkg/hap/tlv8/tlv8.go +++ b/pkg/hap/tlv8/tlv8.go @@ -46,6 +46,8 @@ func Marshal(v any) ([]byte, error) { } switch kind { + case reflect.Slice: + return appendSlice(nil, value) case reflect.Struct: return appendStruct(nil, value) } @@ -53,6 +55,23 @@ func Marshal(v any) ([]byte, error) { return nil, errors.New("tlv8: not implemented: " + kind.String()) } +// separator the most confusing meaning in the documentation. +// It can have a value of 0x00 or 0xFF or even 0x05. +const separator = 0xFF + +func appendSlice(b []byte, value reflect.Value) ([]byte, error) { + for i := 0; i < value.Len(); i++ { + if i > 0 { + b = append(b, separator, 0) + } + var err error + if b, err = appendStruct(b, value.Index(i)); err != nil { + return nil, err + } + } + return b, nil +} + func appendStruct(b []byte, value reflect.Value) ([]byte, error) { valueType := value.Type() @@ -121,7 +140,7 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) { case reflect.Slice: for i := 0; i < value.Len(); i++ { if i > 0 { - b = append(b, 0, 0) + b = append(b, separator, 0) } if b, err = appendValue(b, tag, value.Index(i)); err != nil { return nil, err @@ -179,64 +198,86 @@ func Unmarshal(data []byte, v any) error { kind = value.Kind() } - if kind != reflect.Struct { - return errors.New("tlv8: not implemented: " + kind.String()) + switch kind { + case reflect.Slice: + return unmarshalSlice(data, value) + case reflect.Struct: + return unmarshalStruct(data, value) } - return unmarshalStruct(data, value) + return errors.New("tlv8: not implemented: " + kind.String()) } -func unmarshalStruct(b []byte, value reflect.Value) error { - var waitSlice bool +// unmarshalTLV can return two types of errors: +// - critical and then the value of []byte will be nil +// - not critical and then []byte will contain the value +func unmarshalTLV(b []byte, value reflect.Value) ([]byte, error) { + if len(b) < 2 { + return nil, errors.New("tlv8: wrong size: " + value.Type().Name()) + } - for len(b) >= 2 { - t := b[0] - l := int(b[1]) + t := b[0] + l := int(b[1]) - // array item divider - if t == 0 && l == 0 { - b = b[2:] - waitSlice = true - continue + // array item divider (t == 0x00 || t == 0xFF) + if l == 0 { + return b[2:], errors.New("tlv8: zero item") + } + + var v []byte + + for { + if len(b) < 2+l { + return nil, errors.New("tlv8: wrong size: " + value.Type().Name()) } - var v []byte + v = append(v, b[2:2+l]...) + b = b[2+l:] - for { - if len(b) < 2+l { - return errors.New("tlv8: wrong size: " + value.Type().Name()) + // if size == 255 and same tag - continue read big payload + if l < 255 || len(b) < 2 || b[0] != t { + break + } + + l = int(b[1]) + } + + tag := strconv.Itoa(int(t)) + + valueField, ok := getStructField(value, tag) + if !ok { + return b, fmt.Errorf("tlv8: can't find T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) + } + + if err := unmarshalValue(v, valueField); err != nil { + return nil, err + } + + return b, nil +} + +func unmarshalSlice(b []byte, value reflect.Value) error { + valueIndex := value.Index(growSlice(value)) + for len(b) > 0 { + var err error + if b, err = unmarshalTLV(b, valueIndex); err != nil { + if b != nil { + valueIndex = value.Index(growSlice(value)) + continue } - - v = append(v, b[2:2+l]...) - b = b[2+l:] - - // if size == 255 and same tag - continue read big payload - if l < 255 || len(b) < 2 || b[0] != t { - break - } - - l = int(b[1]) - } - - tag := strconv.Itoa(int(t)) - - valueField, ok := getStructField(value, tag) - if !ok { - return fmt.Errorf("tlv8: can't find T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) - } - - if waitSlice { - if valueField.Kind() != reflect.Slice { - return fmt.Errorf("tlv8: should be slice T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) - } - waitSlice = false - } - - if err := unmarshalValue(v, valueField); err != nil { return err } } + return nil +} +func unmarshalStruct(b []byte, value reflect.Value) error { + for len(b) > 0 { + var err error + if b, err = unmarshalTLV(b, value); b == nil && err != nil { + return err + } + } return nil } diff --git a/pkg/hap/tlv8/tlv8_test.go b/pkg/hap/tlv8/tlv8_test.go index 5ac41fec..bb44c981 100644 --- a/pkg/hap/tlv8/tlv8_test.go +++ b/pkg/hap/tlv8/tlv8_test.go @@ -2,6 +2,7 @@ package tlv8 import ( "encoding/hex" + "strings" "testing" "github.com/stretchr/testify/require" @@ -107,3 +108,49 @@ func TestInterface(t *testing.T) { require.Equal(t, src, dst) } + +func TestSlice1(t *testing.T) { + var v struct { + VideoAttrs []struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` + } `tlv8:"3"` + } + + s := `030b010280070202380403011e ff00 030b010200050202d00203011e` + b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + require.NoError(t, err) + + err = Unmarshal(b1, &v) + require.NoError(t, err) + + require.Len(t, v.VideoAttrs, 2) + + b2, err := Marshal(v) + require.NoError(t, err) + + require.Equal(t, b1, b2) +} + +func TestSlice2(t *testing.T) { + var v []struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` + } + + s := `010280070202380403011e ff00 010200050202d00203011e` + b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + require.NoError(t, err) + + err = Unmarshal(b1, &v) + require.NoError(t, err) + + require.Len(t, v, 2) + + b2, err := Marshal(v) + require.NoError(t, err) + + require.Equal(t, b1, b2) +} From e072842b7a6ccd69cd2ddb8c9df9855df467a042 Mon Sep 17 00:00:00 2001 From: mihailstoynov Date: Sun, 21 Sep 2025 22:56:59 +0300 Subject: [PATCH 32/41] Update README.md example for VGA and HD streams for ease of use --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 13943770..c9b884f3 100644 --- a/README.md +++ b/README.md @@ -531,7 +531,7 @@ streams: - stream quality is the same as [RTSP protocol](https://www.tapo.com/en/faq/34/) - use the **cloud password**, this is not the RTSP password! you do not need to add a login! -- you can also use UPPERCASE MD5 hash from your cloud password with `admin` username +- you can also use **UPPERCASE** MD5 hash from your cloud password with `admin` username - some new camera firmwares require SHA256 instead of MD5 ```yaml @@ -542,6 +542,10 @@ streams: camera2: tapo://admin:UPPERCASE-MD5@192.168.1.123 # admin username and UPPERCASE SHA256 cloud-password hash camera3: tapo://admin:UPPERCASE-SHA256@192.168.1.123 + # VGA stream (the so called substream, the lower resolution one) + camera4: tapo://cloud-password@192.168.1.123?subtype=1 + # HD stream (default) + camera5: tapo://cloud-password@192.168.1.123?subtype=0 ``` ```bash From fd682306e7a236962b3e4ecbf9ce7358a3673551 Mon Sep 17 00:00:00 2001 From: Alex X Date: Mon, 22 Sep 2025 18:11:47 +0300 Subject: [PATCH 33/41] Fix MultiUDPMuxDefault panic #1646 --- pkg/webrtc/api.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/webrtc/api.go b/pkg/webrtc/api.go index fe49ef1e..79cf6d3c 100644 --- a/pkg/webrtc/api.go +++ b/pkg/webrtc/api.go @@ -125,13 +125,20 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error networks = append(networks, ice.NetworkType(ntype)) } - udpMux, _ = ice.NewMultiUDPMuxFromPort( + var err error + if udpMux, err = ice.NewMultiUDPMuxFromPort( port, ice.UDPMuxFromPortWithInterfaceFilter(interfaceFilter), ice.UDPMuxFromPortWithIPFilter(ipFilter), ice.UDPMuxFromPortWithNetworks(networks...), - ) - } else if ln, err := net.ListenPacket("udp", address); err == nil { + ); err != nil { + return nil, err + } + } else { + ln, err := net.ListenPacket("udp", address) + if err != nil { + return nil, err + } udpMux = ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln}) } s.SetICEUDPMux(udpMux) From 26f16e392f9bd3733b131efe477a7e7052e487f4 Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 24 Sep 2025 16:18:18 +0300 Subject: [PATCH 34/41] Update go (build) version to 1.25 and related readme --- .github/workflows/build.yml | 2 +- README.md | 4 ++-- docker/Dockerfile | 2 +- docker/hardware.Dockerfile | 2 +- docker/rockchip.Dockerfile | 2 +- scripts/README.md | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ac4d758d..c802df63 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 - with: { go-version: '1.24' } + with: { go-version: '1.25' } - name: Build go2rtc_win64 env: { GOOS: windows, GOARCH: amd64 } diff --git a/README.md b/README.md index c9b884f3..edaa1159 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Ultimate camera streaming application with support for RTSP, WebRTC, HomeKit, FF Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/): - `go2rtc_win64.zip` - Windows 10+ 64-bit -- `go2rtc_win32.zip` - Windows 7+ 32-bit +- `go2rtc_win32.zip` - Windows 10+ 32-bit - `go2rtc_win_arm64.zip` - Windows ARM 64-bit - `go2rtc_linux_amd64` - Linux 64-bit - `go2rtc_linux_i386` - Linux 32-bit @@ -124,7 +124,7 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2 - `go2rtc_linux_arm` - Linux ARM 32-bit (ex. Raspberry 32-bit OS) - `go2rtc_linux_armv6` - Linux ARMv6 (for old Raspberry 1 and Zero) - `go2rtc_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3), [Wyze cameras](https://github.com/gtxaspec/wz_mini_hacks)) -- `go2rtc_mac_amd64.zip` - macOS 10.13+ Intel 64-bit +- `go2rtc_mac_amd64.zip` - macOS 11+ Intel 64-bit - `go2rtc_mac_arm64.zip` - macOS ARM 64-bit - `go2rtc_freebsd_amd64.zip` - FreeBSD 64-bit - `go2rtc_freebsd_arm64.zip` - FreeBSD ARM 64-bit diff --git a/docker/Dockerfile b/docker/Dockerfile index 34a96757..854ea6c9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,7 @@ # 0. Prepare images ARG PYTHON_VERSION="3.11" -ARG GO_VERSION="1.24" +ARG GO_VERSION="1.25" # 1. Build go2rtc binary diff --git a/docker/hardware.Dockerfile b/docker/hardware.Dockerfile index 03b7d496..a80d08d7 100644 --- a/docker/hardware.Dockerfile +++ b/docker/hardware.Dockerfile @@ -4,7 +4,7 @@ # only debian 13 (trixie) has latest ffmpeg # https://packages.debian.org/trixie/ffmpeg ARG DEBIAN_VERSION="trixie-slim" -ARG GO_VERSION="1.24-bookworm" +ARG GO_VERSION="1.25-bookworm" # 1. Build go2rtc binary diff --git a/docker/rockchip.Dockerfile b/docker/rockchip.Dockerfile index a7a1b450..949db83b 100644 --- a/docker/rockchip.Dockerfile +++ b/docker/rockchip.Dockerfile @@ -2,7 +2,7 @@ # 0. Prepare images ARG PYTHON_VERSION="3.13-slim-bookworm" -ARG GO_VERSION="1.24-bookworm" +ARG GO_VERSION="1.25-bookworm" # 1. Build go2rtc binary diff --git a/scripts/README.md b/scripts/README.md index 9c7f4544..5594915d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ ## Versions +**PS.** Unfortunately, due to the dependency on `pion/webrtc/v4 v4.1.3`, had to upgrade go to `1.23`. Everything described below is not relevant. + [Go 1.20](https://go.dev/doc/go1.20) is last version with support Windows 7 and macOS 10.13. Go 1.21 support only Windows 10 and macOS 10.15. @@ -16,8 +18,6 @@ golang.org/x/sys v0.30.0 // indirect golang.org/x/tools v0.24.0 // indirect ``` -**PS.** Unfortunately, due to the dependency on `pion/webrtc/v4 v4.1.3`, had to upgrade go to `1.23`. - ## Build - UPX-3.96 pack broken bin for `linux_mipsel` From df95ce39d08f4eae0544f7dc340f8d8ee27a5752 Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 24 Sep 2025 16:34:54 +0300 Subject: [PATCH 35/41] Update version to 1.9.10 --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index e85c5900..0cfc31fb 100644 --- a/main.go +++ b/main.go @@ -44,7 +44,7 @@ import ( ) func main() { - app.Version = "1.9.9" + app.Version = "1.9.10" // 1. Core modules: app, api/ws, streams From d697bdcf059c303fcf08edb6303dc3cbc3882b8e Mon Sep 17 00:00:00 2001 From: Alex X Date: Mon, 29 Sep 2025 18:21:36 +0300 Subject: [PATCH 36/41] Code refactoring for #1644 --- www/video-rtc.js | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index 7efa0a57..a239c679 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -580,13 +580,14 @@ 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.')) { - if (VideoRTC.isH265Supported()) rtcPriority += 0x230; - else msePriority += 0x230; - } + if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230; if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210; if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101; @@ -667,23 +668,6 @@ export class VideoRTC extends HTMLElement { this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)}); } - static isH265Supported() { - try { - const videoCodecs = RTCRtpSender?.getCapabilities('video')?.codecs; - - if (!videoCodecs) { - return false; - } - - return videoCodecs.some(codec => - codec.mimeType.toLowerCase().includes('h265') || - codec.mimeType.toLowerCase().includes('hevc') - ); - } catch { - return false; - } - } - static btoa(buffer) { const bytes = new Uint8Array(buffer); const len = bytes.byteLength; From 7d9862202a9c87439bdcffd2d547e8586cdc02a8 Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 30 Sep 2025 12:12:29 +0300 Subject: [PATCH 37/41] Code refactoring for video-rtc.js --- www/video-rtc.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/www/video-rtc.js b/www/video-rtc.js index a239c679..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'}); } } @@ -587,9 +587,9 @@ export class VideoRTC extends HTMLElement { } 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; From c7119f44032fae050b0a7747bf555d17d4ac697e Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 30 Sep 2025 12:14:41 +0300 Subject: [PATCH 38/41] Fix RTP processing for H265 codec (restore VPS,SPS,PPS) --- pkg/h265/rtp.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 From 2b5f9429a8fec87ec1150b9379a563b9d9969e10 Mon Sep 17 00:00:00 2001 From: Alex X Date: Tue, 30 Sep 2025 12:17:41 +0300 Subject: [PATCH 39/41] Update FFmpeg command for encoding H265 (fix profile and level) --- internal/ffmpeg/ffmpeg.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 22cc8ac2c49b636423ee785844a71c761634d736 Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 1 Oct 2025 16:57:39 +0300 Subject: [PATCH 40/41] Code refactoring for #1762 --- internal/streams/api.go | 80 ++++++++----------------- internal/streams/preload.go | 56 +++++++++++++----- internal/streams/streams.go | 16 +++-- pkg/preload/consumer.go | 82 -------------------------- pkg/probe/{producer.go => consumer.go} | 33 ++--------- 5 files changed, 79 insertions(+), 188 deletions(-) delete mode 100644 pkg/preload/consumer.go rename pkg/probe/{producer.go => consumer.go} (60%) diff --git a/internal/streams/api.go b/internal/streams/api.go index 1b91f906..d162cdf9 100644 --- a/internal/streams/api.go +++ b/internal/streams/api.go @@ -5,6 +5,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/probe" ) @@ -27,7 +28,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) { return } - cons := probe.NewProbe(query) + cons := probe.Create("probe", query) if len(cons.Medias) != 0 { cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { @@ -126,73 +127,44 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { func apiPreload(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() src := query.Get("src") - query.Del("src") - if src == "" { - http.Error(w, "no source", http.StatusBadRequest) + // check if stream exists + stream := Get(src) + if stream == nil { + http.Error(w, "", http.StatusNotFound) return } switch r.Method { case "PUT": - // check if stream exists - stream := Get(src) - if stream == nil { - http.Error(w, "stream not found", http.StatusNotFound) + // it's safe to delete from map while iterating + for k := range query { + switch k { + case core.KindVideo, core.KindAudio, "microphone": + default: + delete(query, k) + } + } + + rawQuery := query.Encode() + + if err := AddPreload(stream, rawQuery); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) return } - // check if consumer exists - if cons, ok := preloads[src]; ok { - stream.RemoveConsumer(cons) - delete(preloads, src) - } - - // parse query parameters - var rawQuery string - if query.Has("video") { - if videoQuery := query.Get("video"); videoQuery != "" { - rawQuery += "video=" + videoQuery + "#" - } else { - rawQuery += "video#" - } - } - if query.Has("audio") { - if audioQuery := query.Get("audio"); audioQuery != "" { - rawQuery += "audio=" + audioQuery + "#" - } else { - rawQuery += "audio#" - } - } - if query.Has("microphone") { - if micQuery := query.Get("microphone"); micQuery != "" { - rawQuery += "microphone=" + micQuery + "#" - } else { - rawQuery += "microphone#" - } - } - if err := app.PatchConfig([]string{"preload", src}, rawQuery); err != nil { - log.Error().Err(err).Str("src", src).Msg("Failed to patch config for PUT") - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + case "DELETE": + if err := DelPreload(stream); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) return } - Preload(src, rawQuery) - - case "DELETE": - if cons, ok := preloads[src]; ok { - if stream := Get(src); stream != nil { - stream.RemoveConsumer(cons) - } else { - cons.Stop() - } - - delete(preloads, src) - } - if err := app.PatchConfig([]string{"preload", src}, nil); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusInternalServerError) } default: diff --git a/internal/streams/preload.go b/internal/streams/preload.go index 7314df55..527746ac 100644 --- a/internal/streams/preload.go +++ b/internal/streams/preload.go @@ -1,34 +1,58 @@ package streams import ( + "errors" "net/url" + "sync" - "github.com/AlexxIT/go2rtc/pkg/preload" + "github.com/AlexxIT/go2rtc/pkg/probe" ) -var preloads = map[string]*preload.Preload{} +var preloads = map[*Stream]*probe.Probe{} +var preloadsMu sync.Mutex -func (s *Stream) Preload(name string, query url.Values) error { - cons := preload.NewPreload(name, query) - preloads[name] = cons +func Preload(stream *Stream, rawQuery string) { + if err := AddPreload(stream, rawQuery); err != nil { + log.Error().Err(err).Caller().Send() + } +} - if err := s.AddConsumer(cons); err != nil { +func AddPreload(stream *Stream, rawQuery string) error { + if rawQuery == "" { + rawQuery = "video&audio" + } + + query, err := url.ParseQuery(rawQuery) + if err != nil { return err } + preloadsMu.Lock() + defer preloadsMu.Unlock() + + if cons := preloads[stream]; cons != nil { + stream.RemoveConsumer(cons) + } + + cons := probe.Create("preload", query) + + if err = stream.AddConsumer(cons); err != nil { + return err + } + + preloads[stream] = cons return nil } -func Preload(src string, rawQuery string) { - // skip if exists - if _, ok := preloads[src]; ok { - return +func DelPreload(stream *Stream) error { + preloadsMu.Lock() + defer preloadsMu.Unlock() + + if cons := preloads[stream]; cons != nil { + stream.RemoveConsumer(cons) + delete(preloads, stream) + return nil } - if stream := Get(src); stream != nil { - query := ParseQuery(rawQuery) - if err := stream.Preload(src, query); err != nil { - log.Error().Err(err).Caller().Send() - } - } + return errors.New("streams: preload not found") } diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 8f07ea12..a0b1ed68 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -36,17 +36,15 @@ func Init() { } time.AfterFunc(time.Second, func() { - if cfg.Publish != nil { - for name, dst := range cfg.Publish { - if stream := Get(name); stream != nil { - Publish(stream, dst) - } + // range for nil map is OK + for name, dst := range cfg.Publish { + if stream := Get(name); stream != nil { + Publish(stream, dst) } } - - if cfg.Preload != nil { - for name, rawQuery := range cfg.Preload { - Preload(name, rawQuery) + for name, rawQuery := range cfg.Preload { + if stream := Get(name); stream != nil { + Preload(stream, rawQuery) } } }) diff --git a/pkg/preload/consumer.go b/pkg/preload/consumer.go deleted file mode 100644 index 4d3735a8..00000000 --- a/pkg/preload/consumer.go +++ /dev/null @@ -1,82 +0,0 @@ -package preload - -import ( - "net/url" - "strings" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/pion/rtp" -) - -type Preload struct { - core.Connection - closed core.Waiter -} - -func NewPreload(name string, query url.Values) *Preload { - medias := core.ParseQuery(query) - - for _, value := range query["microphone"] { - media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionRecvonly} - - for _, name := range strings.Split(value, ",") { - name = strings.ToUpper(name) - switch name { - case "", "COPY": - name = core.CodecAny - } - media.Codecs = append(media.Codecs, &core.Codec{Name: name}) - } - - medias = append(medias, media) - } - - if len(medias) == 0 { - medias = []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{{Name: core.CodecAny}}, - }, - { - Kind: core.KindAudio, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{{Name: core.CodecAny}}, - }, - } - } - - return &Preload{ - Connection: core.Connection{ - ID: core.NewID(), - FormatName: "preload", - Medias: medias, - }, - } -} - -func (p *Preload) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - sender := core.NewSender(media, track.Codec) - sender.Handler = func(pkt *rtp.Packet) { - p.Send += pkt.MarshalSize() - } - sender.HandleRTP(track) - p.Senders = append(p.Senders, sender) - return nil -} - -func (p *Preload) Start() error { - p.closed.Wait() - return nil -} - -func (p *Preload) Stop() error { - for _, receiver := range p.Receivers { - receiver.Close() - } - for _, sender := range p.Senders { - sender.Close() - } - p.closed.Done(nil) - return nil -} diff --git a/pkg/probe/producer.go b/pkg/probe/consumer.go similarity index 60% rename from pkg/probe/producer.go rename to pkg/probe/consumer.go index 1fbd3efb..c6aa4478 100644 --- a/pkg/probe/producer.go +++ b/pkg/probe/consumer.go @@ -11,7 +11,7 @@ type Probe struct { core.Connection } -func NewProbe(query url.Values) *Probe { +func Create(name string, query url.Values) *Probe { medias := core.ParseQuery(query) for _, value := range query["microphone"] { @@ -32,39 +32,18 @@ func NewProbe(query url.Values) *Probe { return &Probe{ Connection: core.Connection{ ID: core.NewID(), - FormatName: "probe", + FormatName: name, Medias: medias, }, } } -func (p *Probe) GetMedias() []*core.Media { - return p.Medias -} - func (p *Probe) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { sender := core.NewSender(media, track.Codec) - sender.Bind(track) + sender.Handler = func(pkt *core.Packet) { + p.Send += len(pkt.Payload) + } + sender.HandleRTP(track) p.Senders = append(p.Senders, sender) return nil } - -func (p *Probe) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - receiver := core.NewReceiver(media, codec) - p.Receivers = append(p.Receivers, receiver) - return receiver, nil -} - -func (p *Probe) Start() error { - return nil -} - -func (p *Probe) Stop() error { - for _, receiver := range p.Receivers { - receiver.Close() - } - for _, sender := range p.Senders { - sender.Close() - } - return nil -} From 4dd1f73a189865051c49d73cc6dbbc28b0185018 Mon Sep 17 00:00:00 2001 From: Alex X Date: Wed, 1 Oct 2025 17:03:32 +0300 Subject: [PATCH 41/41] Update readme for #1762 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dfbe79b0..5b3754c9 100644 --- a/README.md +++ b/README.md @@ -844,7 +844,7 @@ You can preload any stream on go2rtc start. This is useful for cameras that take preload: camera1: # default: video&audio = ANY camera2: "video" # preload only video track - camera3: "video=h264#audio=opus" # initialize transcoding pipeline + camera3: "video=h264&audio=opus" # preload H264 video and OPUS audio streams: camera1: