hls: track sessions (#962) (#5683)

sessions are now tracked through cookies or query parameters.

This provides the ability to inspect sessions through logs, metrics and
API, allows more precise tracking of outbound bytes, decreases load on
external HTTP authentication URLs since they are now called once per
session and not once per request.
This commit is contained in:
Alessandro Ros
2026-04-25 21:10:34 +02:00
committed by GitHub
parent c4a1ba332a
commit f87d9e659e
40 changed files with 1694 additions and 252 deletions
+152 -2
View File
@@ -45,8 +45,7 @@ components:
PathReaderType: PathReaderType:
type: string type: string
enum: enum:
- hlsMuxer - hlsSession
- rpiCameraSecondary
- rtmpConn - rtmpConn
- rtmpsConn - rtmpsConn
- rtspConn - rtspConn
@@ -1196,6 +1195,40 @@ components:
items: items:
$ref: '#/components/schemas/HLSMuxer' $ref: '#/components/schemas/HLSMuxer'
HLSSession:
type: object
properties:
id:
type: string
format: uuid
created:
type: string
remoteAddr:
type: string
path:
type: string
query:
type: string
user:
type: string
outboundBytes:
type: integer
format: uint64
HLSSessionList:
type: object
properties:
pageCount:
type: integer
format: int64
itemCount:
type: integer
format: int64
items:
type: array
items:
$ref: '#/components/schemas/HLSSession'
Recording: Recording:
type: object type: object
properties: properties:
@@ -2291,6 +2324,123 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
/v3/hlssessions/list:
get:
operationId: hlssessionsList
tags: [HLS]
summary: returns all HLS sessions.
description: ''
parameters:
- name: page
in: query
description: page number.
schema:
type: integer
default: 0
- name: itemsPerPage
in: query
description: items per page.
schema:
type: integer
default: 100
responses:
'200':
description: the request was successful.
content:
application/json:
schema:
$ref: '#/components/schemas/HLSSessionList'
'400':
description: invalid request.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/v3/hlssessions/get/{id}:
get:
operationId: hlssessionsGet
tags: [HLS]
summary: returns a HLS session.
description: ''
parameters:
- name: id
in: path
required: true
description: ID of the session.
schema:
type: string
responses:
'200':
description: the request was successful.
content:
application/json:
schema:
$ref: '#/components/schemas/HLSSession'
'400':
description: invalid request.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: session not found.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/v3/hlssessions/kick/{id}:
post:
operationId: hlssessionsKick
tags: [HLS]
summary: kicks out a HLS session from the server.
description: ''
parameters:
- name: id
in: path
required: true
description: ID of the session.
schema:
type: string
responses:
'200':
description: the request was successful.
content:
application/json:
schema:
$ref: '#/components/schemas/OK'
'400':
description: invalid request.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: session not found.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/v3/paths/list: /v3/paths/list:
get: get:
operationId: pathsList operationId: pathsList
+6 -1
View File
@@ -20,6 +20,10 @@ paths_inbound_bytes{name="[path_name]",state="[state]"} 1234
paths_outbound_bytes{name="[path_name]",state="[state]"} 1234 paths_outbound_bytes{name="[path_name]",state="[state]"} 1234
paths_inbound_frames_in_error{name="[path_name]",state="[state]"} 1234 paths_inbound_frames_in_error{name="[path_name]",state="[state]"} 1234
# HLS sessions
hls_sessions{id="[id]",path="[path]",remoteAddr="[remoteAddr]"} 1
hls_sessions_outbound_bytes{id="[id]",path="[path]",remoteAddr="[remoteAddr]"} 187
# HLS muxers # HLS muxers
hls_muxers{name="[name]"} 1 hls_muxers{name="[name]"} 1
hls_muxers_outbound_bytes{name="[name]"} 187 hls_muxers_outbound_bytes{name="[name]"} 187
@@ -151,9 +155,10 @@ Bitrates are not provided directly as metrics because they can be computed from
Metrics can be filtered by using HTTP query parameters: Metrics can be filtered by using HTTP query parameters:
- `type=[TYPE]`: show metrics of a certain type only. TYPE can be `paths`, `hls_muxers`, `rtsp_conns`, `rtsp_sessions`, `rtsps_conns`, `rtsps_sessions`, `rtmp_conns`, `rtmps_conns`, `srt_conns`, `webrtc_sessions`. - `type=[TYPE]`: show metrics of a certain type only. TYPE can be `paths`, `hls_sessions`, `hls_muxers`, `rtsp_conns`, `rtsp_sessions`, `rtsps_conns`, `rtsps_sessions`, `rtmp_conns`, `rtmps_conns`, `srt_conns`, `webrtc_sessions`.
- `path=[PATH]`: show metrics belonging to a specific path only - `path=[PATH]`: show metrics belonging to a specific path only
- `hls_muxer=[PATH]`: show metrics belonging to a specific HLS muxer only - `hls_muxer=[PATH]`: show metrics belonging to a specific HLS muxer only
- `hls_session=[ID]`: show metrics belonging to a specific HLS session only
- `rtsp_conn=[ID]` show metrics belonging to a specific RTSP connection only - `rtsp_conn=[ID]` show metrics belonging to a specific RTSP connection only
- `rtsp_session=[SESSION]`: show metrics belonging to a specific RTSP session only - `rtsp_session=[SESSION]`: show metrics belonging to a specific RTSP session only
- `rtsps_conn=[ID]` show metrics belonging to a specific RTSPS connection only - `rtsps_conn=[ID]` show metrics belonging to a specific RTSPS connection only
+2
View File
@@ -164,6 +164,8 @@ After the video tag, add a script that initializes the stream when the page is f
if (Hls.isSupported()) { if (Hls.isSupported()) {
const hls = new Hls({ const hls = new Hls({
xhrSetup: function (xhr, url) { xhrSetup: function (xhr, url) {
xhr.withCredentials = true;
let user = ""; // fill if needed let user = ""; // fill if needed
let pass = ""; // fill if needed let pass = ""; // fill if needed
let token = ""; // fill if needed let token = ""; // fill if needed
+3
View File
@@ -120,6 +120,9 @@ func (a *API) Initialize() error {
if !interfaceIsEmpty(a.HLSServer) { if !interfaceIsEmpty(a.HLSServer) {
group.GET("/hlsmuxers/list", a.onHLSMuxersList) group.GET("/hlsmuxers/list", a.onHLSMuxersList)
group.GET("/hlsmuxers/get/*name", a.onHLSMuxersGet) group.GET("/hlsmuxers/get/*name", a.onHLSMuxersGet)
group.GET("/hlssessions/list", a.onHLSSessionsList)
group.GET("/hlssessions/get/:id", a.onHLSSessionsGet)
group.POST("/hlssessions/kick/:id", a.onHLSSessionsKick)
} }
if !interfaceIsEmpty(a.RTSPServer) { if !interfaceIsEmpty(a.RTSPServer) {
+61
View File
@@ -8,6 +8,7 @@ import (
"github.com/bluenviron/mediamtx/internal/servers/hls" "github.com/bluenviron/mediamtx/internal/servers/hls"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
func (a *API) onHLSMuxersList(ctx *gin.Context) { func (a *API) onHLSMuxersList(ctx *gin.Context) {
@@ -47,3 +48,63 @@ func (a *API) onHLSMuxersGet(ctx *gin.Context) {
ctx.JSON(http.StatusOK, data) ctx.JSON(http.StatusOK, data)
} }
func (a *API) onHLSSessionsList(ctx *gin.Context) {
data, err := a.HLSServer.APISessionsList()
if err != nil {
a.writeError(ctx, http.StatusInternalServerError, err)
return
}
data.ItemCount = len(data.Items)
pageCount, err := paginate(&data.Items, ctx.Query("itemsPerPage"), ctx.Query("page"))
if err != nil {
a.writeError(ctx, http.StatusBadRequest, err)
return
}
data.PageCount = pageCount
ctx.JSON(http.StatusOK, data)
}
func (a *API) onHLSSessionsGet(ctx *gin.Context) {
id := ctx.Param("id")
uuid, err := uuid.Parse(id)
if err != nil {
a.writeError(ctx, http.StatusBadRequest, err)
return
}
data, err := a.HLSServer.APISessionsGet(uuid)
if err != nil {
if errors.Is(err, hls.ErrSessionNotFound) {
a.writeError(ctx, http.StatusNotFound, err)
} else {
a.writeError(ctx, http.StatusInternalServerError, err)
}
return
}
ctx.JSON(http.StatusOK, data)
}
func (a *API) onHLSSessionsKick(ctx *gin.Context) {
uuid, err := uuid.Parse(ctx.Param("id"))
if err != nil {
a.writeError(ctx, http.StatusBadRequest, err)
return
}
err = a.HLSServer.APISessionsKick(uuid)
if err != nil {
if errors.Is(err, hls.ErrSessionNotFound) {
a.writeError(ctx, http.StatusNotFound, err)
} else {
a.writeError(ctx, http.StatusInternalServerError, err)
}
return
}
a.writeOK(ctx)
}
+219 -3
View File
@@ -1,7 +1,9 @@
package api //nolint:revive package api //nolint:revive
import ( import (
"fmt"
"net/http" "net/http"
"sort"
"testing" "testing"
"time" "time"
@@ -9,13 +11,17 @@ import (
"github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/servers/hls" "github.com/bluenviron/mediamtx/internal/servers/hls"
"github.com/bluenviron/mediamtx/internal/test" "github.com/bluenviron/mediamtx/internal/test"
"github.com/google/uuid"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
type testHLSServer struct { type testHLSServer struct {
muxers map[string]*defs.APIHLSMuxer muxers map[string]*defs.APIHLSMuxer
sessions map[string]*defs.APIHLSSession
} }
var testTime = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
func (s *testHLSServer) APIMuxersList() (*defs.APIHLSMuxerList, error) { func (s *testHLSServer) APIMuxersList() (*defs.APIHLSMuxerList, error) {
items := make([]defs.APIHLSMuxer, 0, len(s.muxers)) items := make([]defs.APIHLSMuxer, 0, len(s.muxers))
for _, muxer := range s.muxers { for _, muxer := range s.muxers {
@@ -32,8 +38,36 @@ func (s *testHLSServer) APIMuxersGet(name string) (*defs.APIHLSMuxer, error) {
return muxer, nil return muxer, nil
} }
func (s *testHLSServer) APISessionsList() (*defs.APIHLSSessionList, error) {
items := make([]defs.APIHLSSession, 0, len(s.sessions))
for _, session := range s.sessions {
items = append(items, *session)
}
sort.Slice(items, func(i, j int) bool {
return items[i].Created.Before(items[j].Created)
})
return &defs.APIHLSSessionList{Items: items}, nil
}
func (s *testHLSServer) APISessionsGet(id uuid.UUID) (*defs.APIHLSSession, error) {
session, ok := s.sessions[id.String()]
if !ok {
return nil, hls.ErrSessionNotFound
}
return session, nil
}
func (s *testHLSServer) APISessionsKick(id uuid.UUID) error {
_, ok := s.sessions[id.String()]
if !ok {
return hls.ErrSessionNotFound
}
delete(s.sessions, id.String())
return nil
}
func TestHLSMuxersList(t *testing.T) { func TestHLSMuxersList(t *testing.T) {
now := time.Now() now := testTime
hlsServer := &testHLSServer{ hlsServer := &testHLSServer{
muxers: map[string]*defs.APIHLSMuxer{ muxers: map[string]*defs.APIHLSMuxer{
"test1": { "test1": {
@@ -80,7 +114,7 @@ func TestHLSMuxersList(t *testing.T) {
} }
func TestHLSMuxersGet(t *testing.T) { func TestHLSMuxersGet(t *testing.T) {
now := time.Now() now := testTime
hlsServer := &testHLSServer{ hlsServer := &testHLSServer{
muxers: map[string]*defs.APIHLSMuxer{ muxers: map[string]*defs.APIHLSMuxer{
"mypath": { "mypath": {
@@ -118,3 +152,185 @@ func TestHLSMuxersGet(t *testing.T) {
require.Equal(t, uint64(12), out.OutboundFramesDiscarded) require.Equal(t, uint64(12), out.OutboundFramesDiscarded)
require.Equal(t, uint64(9999), out.BytesSent) require.Equal(t, uint64(9999), out.BytesSent)
} }
func TestHLSSessionsList(t *testing.T) {
now := testTime
hlsServer := &testHLSServer{
sessions: map[string]*defs.APIHLSSession{
"session1": {
ID: uuid.MustParse("18294761-f9d1-4ea9-9a35-fe265b62eb41"),
Created: now,
RemoteAddr: "192.168.1.1:5000",
Path: "stream1",
Query: "key=val1",
User: "user1",
OutboundBytes: 111,
},
"session2": {
ID: uuid.MustParse("18294761-f9d1-4ea9-9a35-fe265b62eb42"),
Created: now.Add(time.Minute),
RemoteAddr: "192.168.1.2:5001",
Path: "stream2",
Query: "key=val2",
User: "user2",
OutboundBytes: 222,
},
},
}
api := API{
Address: "localhost:9997",
ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second),
AuthManager: test.NilAuthManager,
HLSServer: hlsServer,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
var out defs.APIHLSSessionList
httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/hlssessions/list", nil, &out)
require.Equal(t, 2, out.ItemCount)
require.Equal(t, 1, out.PageCount)
require.Len(t, out.Items, 2)
require.Equal(t, []defs.APIHLSSession{
{
ID: uuid.MustParse("18294761-f9d1-4ea9-9a35-fe265b62eb41"),
Created: now,
RemoteAddr: "192.168.1.1:5000",
Path: "stream1",
Query: "key=val1",
User: "user1",
OutboundBytes: 111,
},
{
ID: uuid.MustParse("18294761-f9d1-4ea9-9a35-fe265b62eb42"),
Created: now.Add(time.Minute),
RemoteAddr: "192.168.1.2:5001",
Path: "stream2",
Query: "key=val2",
User: "user2",
OutboundBytes: 222,
},
}, out.Items)
}
func TestHLSSessionsGet(t *testing.T) {
now := testTime
hlsServer := &testHLSServer{
sessions: map[string]*defs.APIHLSSession{
"18294761-f9d1-4ea9-9a35-fe265b62eb41": {
ID: uuid.MustParse("18294761-f9d1-4ea9-9a35-fe265b62eb41"),
Created: now,
RemoteAddr: "192.168.1.100:5000",
Path: "mystream",
Query: "key=val",
User: "myuser",
OutboundBytes: 345,
},
},
}
api := API{
Address: "localhost:9997",
ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second),
AuthManager: test.NilAuthManager,
HLSServer: hlsServer,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
sessionID := "18294761-f9d1-4ea9-9a35-fe265b62eb41"
var out defs.APIHLSSession
httpRequest(t, hc, http.MethodGet, fmt.Sprintf("http://localhost:9997/v3/hlssessions/get/%s", sessionID), nil, &out)
require.Equal(t, uuid.MustParse(sessionID), out.ID)
require.Equal(t, "192.168.1.100:5000", out.RemoteAddr)
require.Equal(t, "mystream", out.Path)
require.Equal(t, "key=val", out.Query)
require.Equal(t, "myuser", out.User)
require.Equal(t, uint64(345), out.OutboundBytes)
}
func TestHLSSessionsKick(t *testing.T) {
now := testTime
sessionID := uuid.MustParse("18294761-f9d1-4ea9-9a35-fe265b62eb41")
hlsServer := &testHLSServer{
sessions: map[string]*defs.APIHLSSession{
sessionID.String(): {
ID: sessionID,
Created: now,
RemoteAddr: "192.168.1.100:5000",
Path: "mystream",
OutboundBytes: 345,
},
},
}
api := API{
Address: "localhost:9997",
ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second),
AuthManager: test.NilAuthManager,
HLSServer: hlsServer,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
httpRequest(t, hc, http.MethodPost, fmt.Sprintf("http://localhost:9997/v3/hlssessions/kick/%s", sessionID), nil, nil)
_, ok := hlsServer.sessions[sessionID.String()]
require.False(t, ok)
}
func TestHLSSessionsKickNotFound(t *testing.T) {
hlsServer := &testHLSServer{}
api := API{
Address: "localhost:9997",
ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second),
AuthManager: test.NilAuthManager,
HLSServer: hlsServer,
Parent: &testParent{},
}
err := api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
req, err := http.NewRequest(http.MethodPost,
fmt.Sprintf("http://localhost:9997/v3/hlssessions/kick/%s", uuid.New()), nil)
require.NoError(t, err)
res, err := hc.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
checkError(t, res.Body, "session not found")
}
+1 -1
View File
@@ -108,7 +108,7 @@ func TestPathsGet(t *testing.T) {
BytesReceived: 123456, BytesReceived: 123456,
BytesSent: 789012, BytesSent: 789012,
Readers: []defs.APIPathReader{ Readers: []defs.APIPathReader{
{Type: defs.APIPathReaderTypeHLSMuxer, ID: "muxer1"}, {Type: defs.APIPathReaderTypeHLSSession, ID: "session6123"},
{Type: defs.APIPathReaderTypeWebRTCSession, ID: "session456"}, {Type: defs.APIPathReaderTypeWebRTCSession, ID: "session456"},
}, },
}, },
+1 -1
View File
@@ -7,6 +7,6 @@ type Error struct {
} }
// Error implements the error interface. // Error implements the error interface.
func (e Error) Error() string { func (e *Error) Error() string {
return "authentication failed: " + e.Wrapped.Error() return "authentication failed: " + e.Wrapped.Error()
} }
+87 -16
View File
@@ -14,10 +14,13 @@ import (
"testing" "testing"
"time" "time"
"github.com/bluenviron/gohlslib/v2"
"github.com/bluenviron/gortmplib" "github.com/bluenviron/gortmplib"
rtmpcodecs "github.com/bluenviron/gortmplib/pkg/codecs" rtmpcodecs "github.com/bluenviron/gortmplib/pkg/codecs"
"github.com/bluenviron/gortsplib/v5" "github.com/bluenviron/gortsplib/v5"
"github.com/bluenviron/gortsplib/v5/pkg/description" "github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/format"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtph264"
"github.com/bluenviron/mediacommon/v2/pkg/formats/mpegts" "github.com/bluenviron/mediacommon/v2/pkg/formats/mpegts"
tscodecs "github.com/bluenviron/mediacommon/v2/pkg/formats/mpegts/codecs" tscodecs "github.com/bluenviron/mediacommon/v2/pkg/formats/mpegts/codecs"
srt "github.com/datarhei/gosrt" srt "github.com/datarhei/gosrt"
@@ -369,7 +372,8 @@ func TestAPIProtocolListGet(t *testing.T) {
"rtsps sessions", "rtsps sessions",
"rtmp", "rtmp",
"rtmps", "rtmps",
"hls", "hls sessions",
"hls muxers",
"webrtc", "webrtc",
"srt", "srt",
} { } {
@@ -470,7 +474,7 @@ func TestAPIProtocolListGet(t *testing.T) {
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
case "hls": case "hls sessions", "hls muxers":
source := gortsplib.Client{} source := gortsplib.Client{}
err = source.StartRecording("rtsp://localhost:8554/mypath", err = source.StartRecording("rtsp://localhost:8554/mypath",
&description.Session{Medias: []*description.Media{medi}}) &description.Session{Medias: []*description.Media{medi}})
@@ -612,7 +616,10 @@ func TestAPIProtocolListGet(t *testing.T) {
case "rtmps": case "rtmps":
pa = "rtmpsconns" pa = "rtmpsconns"
case "hls": case "hls sessions":
pa = "hlssessions"
case "hls muxers":
pa = "hlsmuxers" pa = "hlsmuxers"
case "webrtc": case "webrtc":
@@ -792,7 +799,24 @@ func TestAPIProtocolListGet(t *testing.T) {
}, },
}, out1) }, out1)
case "hls": case "hls sessions":
require.Equal(t, map[string]any{
"itemCount": float64(1),
"pageCount": float64(1),
"items": []any{
map[string]any{
"id": out1.(map[string]any)["items"].([]any)[0].(map[string]any)["id"],
"created": out1.(map[string]any)["items"].([]any)[0].(map[string]any)["created"],
"remoteAddr": out1.(map[string]any)["items"].([]any)[0].(map[string]any)["remoteAddr"],
"path": "mypath",
"query": "",
"user": "",
"outboundBytes": out1.(map[string]any)["items"].([]any)[0].(map[string]any)["outboundBytes"],
},
},
}, out1)
case "hls muxers":
require.Equal(t, map[string]any{ require.Equal(t, map[string]any{
"itemCount": float64(1), "itemCount": float64(1),
"pageCount": float64(1), "pageCount": float64(1),
@@ -919,7 +943,7 @@ func TestAPIProtocolListGet(t *testing.T) {
var out2 any var out2 any
if ca == "hls" { if ca == "hls muxers" {
httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/"+pa+"/get/"+ httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/"+pa+"/get/"+
out1.(map[string]any)["items"].([]any)[0].(map[string]any)["path"].(string), out1.(map[string]any)["items"].([]any)[0].(map[string]any)["path"].(string),
nil, &out2) nil, &out2)
@@ -979,7 +1003,7 @@ func TestAPIProtocolListGet(t *testing.T) {
out2.(map[string]any)["rtcpPacketsReceived"] = out1.(map[string]any)["items"].([]any)[0].(map[string]any)["rtcpPacketsReceived"] out2.(map[string]any)["rtcpPacketsReceived"] = out1.(map[string]any)["items"].([]any)[0].(map[string]any)["rtcpPacketsReceived"]
out2.(map[string]any)["rtcpPacketsSent"] = out1.(map[string]any)["items"].([]any)[0].(map[string]any)["rtcpPacketsSent"] out2.(map[string]any)["rtcpPacketsSent"] = out1.(map[string]any)["items"].([]any)[0].(map[string]any)["rtcpPacketsSent"]
case "hls": case "hls muxers":
out2.(map[string]any)["lastRequest"] = out1.(map[string]any)["items"].([]any)[0].(map[string]any)["lastRequest"] out2.(map[string]any)["lastRequest"] = out1.(map[string]any)["items"].([]any)[0].(map[string]any)["lastRequest"]
} }
@@ -1004,7 +1028,8 @@ func TestAPIProtocolGetNotFound(t *testing.T) {
"rtsps sessions", "rtsps sessions",
"rtmp", "rtmp",
"rtmps", "rtmps",
"hls", "hls sessions",
"hls muxers",
"webrtc", "webrtc",
"srt", "srt",
} { } {
@@ -1055,7 +1080,10 @@ func TestAPIProtocolGetNotFound(t *testing.T) {
case "rtmps": case "rtmps":
pa = "rtmpsconns" pa = "rtmpsconns"
case "hls": case "hls sessions":
pa = "hlssessions"
case "hls muxers":
pa = "hlsmuxers" pa = "hlsmuxers"
case "webrtc": case "webrtc":
@@ -1081,10 +1109,10 @@ func TestAPIProtocolGetNotFound(t *testing.T) {
case "rtsp conns", "rtsps conns", "rtmp", "rtmps", "srt": case "rtsp conns", "rtsps conns", "rtmp", "rtmps", "srt":
checkError(t, "connection not found", res.Body) checkError(t, "connection not found", res.Body)
case "rtsp sessions", "rtsps sessions", "webrtc": case "rtsp sessions", "rtsps sessions", "hls sessions", "webrtc":
checkError(t, "session not found", res.Body) checkError(t, "session not found", res.Body)
case "hls": case "hls muxers":
checkError(t, "muxer not found", res.Body) checkError(t, "muxer not found", res.Body)
} }
}() }()
@@ -1105,6 +1133,7 @@ func TestAPIProtocolKick(t *testing.T) {
"rtsp", "rtsp",
"rtsps", "rtsps",
"rtmp", "rtmp",
"hls",
"webrtc", "webrtc",
"srt", "srt",
} { } {
@@ -1134,7 +1163,6 @@ func TestAPIProtocolKick(t *testing.T) {
switch ca { switch ca {
case "rtsp": case "rtsp":
source := gortsplib.Client{} source := gortsplib.Client{}
err = source.StartRecording("rtsp://localhost:8554/mypath", err = source.StartRecording("rtsp://localhost:8554/mypath",
&description.Session{Medias: []*description.Media{medi}}) &description.Session{Medias: []*description.Media{medi}})
require.NoError(t, err) require.NoError(t, err)
@@ -1144,7 +1172,6 @@ func TestAPIProtocolKick(t *testing.T) {
source := gortsplib.Client{ source := gortsplib.Client{
TLSConfig: &tls.Config{InsecureSkipVerify: true}, TLSConfig: &tls.Config{InsecureSkipVerify: true},
} }
err = source.StartRecording("rtsps://localhost:8322/mypath", err = source.StartRecording("rtsps://localhost:8322/mypath",
&description.Session{Medias: []*description.Media{medi}}) &description.Session{Medias: []*description.Media{medi}})
require.NoError(t, err) require.NoError(t, err)
@@ -1180,6 +1207,45 @@ func TestAPIProtocolKick(t *testing.T) {
err = w.WriteH264(track, 2*time.Second, 2*time.Second, [][]byte{{5, 2, 3, 4}}) err = w.WriteH264(track, 2*time.Second, 2*time.Second, [][]byte{{5, 2, 3, 4}})
require.NoError(t, err) require.NoError(t, err)
case "hls":
source := gortsplib.Client{}
err = source.StartRecording("rtsp://localhost:8554/mypath",
&description.Session{Medias: []*description.Media{medi}})
require.NoError(t, err)
defer source.Close()
var enc *rtph264.Encoder
enc, err = medi.Formats[0].(*format.H264).CreateEncoder()
require.NoError(t, err)
tracksReceived := make(chan struct{})
client := &gohlslib.Client{
URI: "http://localhost:8888/mypath/index.m3u8",
OnTracks: func(_ []*gohlslib.Track) error {
close(tracksReceived)
return nil
},
}
err = client.Start()
require.NoError(t, err)
defer client.Close()
time.Sleep(500 * time.Millisecond)
for i := range 2 {
var pkts []*rtp.Packet
pkts, err = enc.Encode([][]byte{{5, 2, 3, 4}})
require.NoError(t, err)
pkts[0].Timestamp = uint32(i * 90000)
err = source.WritePacketRTP(medi, pkts[0])
require.NoError(t, err)
}
<-tracksReceived
case "webrtc": case "webrtc":
var u *url.URL var u *url.URL
u, err = url.Parse("http://localhost:8889/mypath/whip") u, err = url.Parse("http://localhost:8889/mypath/whip")
@@ -1243,6 +1309,9 @@ func TestAPIProtocolKick(t *testing.T) {
case "rtmp": case "rtmp":
pa = "rtmpconns" pa = "rtmpconns"
case "hls":
pa = "hlssessions"
case "webrtc": case "webrtc":
pa = "webrtcsessions" pa = "webrtcsessions"
@@ -1256,6 +1325,7 @@ func TestAPIProtocolKick(t *testing.T) {
} `json:"items"` } `json:"items"`
} }
httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/"+pa+"/list", nil, &out1) httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/"+pa+"/list", nil, &out1)
require.NotEmpty(t, out1.Items)
httpRequest(t, hc, http.MethodPost, "http://localhost:9997/v3/"+pa+"/kick/"+out1.Items[0].ID, nil, nil) httpRequest(t, hc, http.MethodPost, "http://localhost:9997/v3/"+pa+"/kick/"+out1.Items[0].ID, nil, nil)
@@ -1283,6 +1353,7 @@ func TestAPIProtocolKickNotFound(t *testing.T) {
"rtsp", "rtsp",
"rtsps", "rtsps",
"rtmp", "rtmp",
"hls",
"webrtc", "webrtc",
"srt", "srt",
} { } {
@@ -1318,6 +1389,9 @@ func TestAPIProtocolKickNotFound(t *testing.T) {
case "rtmp": case "rtmp":
pa = "rtmpconns" pa = "rtmpconns"
case "hls":
pa = "hlssessions"
case "webrtc": case "webrtc":
pa = "webrtcsessions" pa = "webrtcsessions"
@@ -1341,11 +1415,8 @@ func TestAPIProtocolKickNotFound(t *testing.T) {
case "rtsp conns", "rtsps conns", "rtmp", "rtmps", "srt": case "rtsp conns", "rtsps conns", "rtmp", "rtmps", "srt":
checkError(t, "connection not found", res.Body) checkError(t, "connection not found", res.Body)
case "rtsp sessions", "rtsps sessions", "webrtc": case "rtsp sessions", "rtsps sessions", "hls", "webrtc":
checkError(t, "session not found", res.Body) checkError(t, "session not found", res.Body)
case "hls":
checkError(t, "muxer not found", res.Body)
} }
}() }()
}) })
+1
View File
@@ -609,6 +609,7 @@ func (p *Core) createResources(initial bool) error {
ReadTimeout: p.conf.ReadTimeout, ReadTimeout: p.conf.ReadTimeout,
WriteTimeout: p.conf.WriteTimeout, WriteTimeout: p.conf.WriteTimeout,
MuxerCloseAfter: p.conf.HLSMuxerCloseAfter, MuxerCloseAfter: p.conf.HLSMuxerCloseAfter,
ExternalCmdPool: p.externalCmdPool,
Metrics: p.metrics, Metrics: p.metrics,
PathManager: p.pathManager, PathManager: p.pathManager,
Parent: p, Parent: p,
+4
View File
@@ -86,6 +86,10 @@ paths_bytes_received 0
paths_bytes_sent 0 paths_bytes_sent 0
paths_readers 0 paths_readers 0
# HLS sessions
hls_sessions 0
hls_sessions_outbound_bytes 0
# HLS muxers # HLS muxers
hls_muxers 0 hls_muxers 0
hls_muxers_outbound_bytes 0 hls_muxers_outbound_bytes 0
+8 -6
View File
@@ -506,7 +506,7 @@ func (pa *path) doDescribe(req defs.PathDescribeReq) {
return return
} }
req.Res <- defs.PathDescribeRes{Err: defs.PathNoStreamAvailableError{PathName: pa.name}} req.Res <- defs.PathDescribeRes{Err: &defs.PathNoStreamAvailableError{PathName: pa.name}}
} }
func (pa *path) doRemovePublisher(req defs.PathRemovePublisherReq) { func (pa *path) doRemovePublisher(req defs.PathRemovePublisherReq) {
@@ -598,7 +598,7 @@ func (pa *path) doAddReader(req defs.PathAddReaderReq) {
return return
} }
req.Res <- defs.PathAddReaderRes{Err: defs.PathNoStreamAvailableError{PathName: pa.name}} req.Res <- defs.PathAddReaderRes{Err: &defs.PathNoStreamAvailableError{PathName: pa.name}}
} }
func (pa *path) doRemoveReader(req defs.PathRemoveReaderReq) { func (pa *path) doRemoveReader(req defs.PathRemoveReaderReq) {
@@ -699,11 +699,13 @@ func (pa *path) doAPIPathsGet(req pathAPIPathsGetReq) {
return pa.stream.OutboundBytes() return pa.stream.OutboundBytes()
}(), }(),
Readers: func() []defs.APIPathReader { Readers: func() []defs.APIPathReader {
ret := make([]defs.APIPathReader, len(pa.readers)) ret := make([]defs.APIPathReader, 0, len(pa.readers))
i := 0
for r := range pa.readers { for r := range pa.readers {
ret[i] = *r.APIReaderDescribe() desc := *r.APIReaderDescribe()
i++ if desc.Type != defs.APIPathReaderTypeHidden {
ret = append(ret, desc)
}
} }
sort.Slice(ret, func(i, j int) bool { sort.Slice(ret, func(i, j int) bool {
+33 -8
View File
@@ -1,13 +1,45 @@
package defs package defs
import "time" import (
"time"
"github.com/google/uuid"
)
// APIHLSServer contains methods used by the API and Metrics server. // APIHLSServer contains methods used by the API and Metrics server.
type APIHLSServer interface { type APIHLSServer interface {
APISessionsList() (*APIHLSSessionList, error)
APISessionsGet(uuid.UUID) (*APIHLSSession, error)
APISessionsKick(uuid.UUID) error
APIMuxersList() (*APIHLSMuxerList, error) APIMuxersList() (*APIHLSMuxerList, error)
APIMuxersGet(string) (*APIHLSMuxer, error) APIMuxersGet(string) (*APIHLSMuxer, error)
} }
// APIHLSSessionList is a list of HLS sessions.
type APIHLSSessionList struct {
ItemCount int `json:"itemCount"`
PageCount int `json:"pageCount"`
Items []APIHLSSession `json:"items"`
}
// APIHLSSession is an HLS session.
type APIHLSSession struct {
ID uuid.UUID `json:"id"`
Created time.Time `json:"created"`
RemoteAddr string `json:"remoteAddr"`
Path string `json:"path"`
Query string `json:"query"`
User string `json:"user"`
OutboundBytes uint64 `json:"outboundBytes"`
}
// APIHLSMuxerList is a list of HLS muxers.
type APIHLSMuxerList struct {
ItemCount int `json:"itemCount"`
PageCount int `json:"pageCount"`
Items []APIHLSMuxer `json:"items"`
}
// APIHLSMuxer is an HLS muxer. // APIHLSMuxer is an HLS muxer.
type APIHLSMuxer struct { type APIHLSMuxer struct {
Path string `json:"path"` Path string `json:"path"`
@@ -18,10 +50,3 @@ type APIHLSMuxer struct {
// deprecated // deprecated
BytesSent uint64 `json:"bytesSent" deprecated:"true"` BytesSent uint64 `json:"bytesSent" deprecated:"true"`
} }
// APIHLSMuxerList is a list of HLS muxers.
type APIHLSMuxerList struct {
ItemCount int `json:"itemCount"`
PageCount int `json:"pageCount"`
Items []APIHLSMuxer `json:"items"`
}
+10 -10
View File
@@ -43,16 +43,16 @@ type APIPathReaderType string
// reader types. // reader types.
const ( const (
APIPathReaderTypeHLSMuxer APIPathReaderType = "hlsMuxer" APIPathReaderTypeHLSSession APIPathReaderType = "hlsSession"
APIPathReaderTypeRTMPConn APIPathReaderType = "rtmpConn" APIPathReaderTypeRTMPConn APIPathReaderType = "rtmpConn"
APIPathReaderTypeRTMPSConn APIPathReaderType = "rtmpsConn" APIPathReaderTypeRTMPSConn APIPathReaderType = "rtmpsConn"
APIPathReaderTypeRTSPConn APIPathReaderType = "rtspConn" APIPathReaderTypeRTSPConn APIPathReaderType = "rtspConn"
APIPathReaderTypeRPICameraSecondary APIPathReaderType = "rpiCameraSecondary" APIPathReaderTypeRTSPSession APIPathReaderType = "rtspSession"
APIPathReaderTypeRTSPSession APIPathReaderType = "rtspSession" APIPathReaderTypeRTSPSConn APIPathReaderType = "rtspsConn"
APIPathReaderTypeRTSPSConn APIPathReaderType = "rtspsConn" APIPathReaderTypeRTSPSSession APIPathReaderType = "rtspsSession"
APIPathReaderTypeRTSPSSession APIPathReaderType = "rtspsSession" APIPathReaderTypeSRTConn APIPathReaderType = "srtConn"
APIPathReaderTypeSRTConn APIPathReaderType = "srtConn" APIPathReaderTypeWebRTCSession APIPathReaderType = "webRTCSession"
APIPathReaderTypeWebRTCSession APIPathReaderType = "webRTCSession" APIPathReaderTypeHidden APIPathReaderType = "hidden"
) )
// APIPathReader is a reader. // APIPathReader is a reader.
+1 -1
View File
@@ -16,7 +16,7 @@ type PathNoStreamAvailableError struct {
} }
// Error implements the error interface. // Error implements the error interface.
func (e PathNoStreamAvailableError) Error() string { func (e *PathNoStreamAvailableError) Error() string {
return fmt.Sprintf("no stream is available on path '%s'", e.PathName) return fmt.Sprintf("no stream is available on path '%s'", e.PathName)
} }
+9 -2
View File
@@ -175,8 +175,7 @@ func goEnumToApi(rt reflect.Type) (openAPISchema, bool) {
case reflect.TypeOf(defs.APIPathReaderType("")): case reflect.TypeOf(defs.APIPathReaderType("")):
return openAPISchema{Type: "string", Enum: []string{ return openAPISchema{Type: "string", Enum: []string{
"hlsMuxer", "hlsSession",
"rpiCameraSecondary",
"rtmpConn", "rtmpConn",
"rtmpsConn", "rtmpsConn",
"rtspConn", "rtspConn",
@@ -352,6 +351,14 @@ func TestGo2API(t *testing.T) {
"HLSMuxerList", "HLSMuxerList",
defs.APIHLSMuxerList{}, defs.APIHLSMuxerList{},
}, },
{
"HLSSession",
defs.APIHLSSession{},
},
{
"HLSSessionList",
defs.APIHLSSessionList{},
},
{ {
"Info", "Info",
defs.APIInfo{}, defs.APIInfo{},
+29
View File
@@ -74,6 +74,7 @@ type metricsType string
const ( const (
metricsTypePaths metricsType = "paths" metricsTypePaths metricsType = "paths"
metricsTypeHLSSessions metricsType = "hls_sessions"
metricsTypeHLSMuxers metricsType = "hls_muxers" metricsTypeHLSMuxers metricsType = "hls_muxers"
metricsTypeRTSPConns metricsType = "rtsp_conns" metricsTypeRTSPConns metricsType = "rtsp_conns"
metricsTypeRTSPSessions metricsType = "rtsp_sessions" metricsTypeRTSPSessions metricsType = "rtsp_sessions"
@@ -227,6 +228,7 @@ func (m *Metrics) onMetrics(ctx *gin.Context) {
typ := metricsType(ctx.Query("type")) typ := metricsType(ctx.Query("type"))
pathFilter := ctx.Query("path") pathFilter := ctx.Query("path")
hlsMuxerFilter := ctx.Query("hls_muxer") hlsMuxerFilter := ctx.Query("hls_muxer")
hlsSessionFilter := ctx.Query("hls_session")
rtspConnFilter := ctx.Query("rtsp_conn") rtspConnFilter := ctx.Query("rtsp_conn")
rtspSessionFilter := ctx.Query("rtsp_session") rtspSessionFilter := ctx.Query("rtsp_session")
rtspsConnFilter := ctx.Query("rtsps_conn") rtspsConnFilter := ctx.Query("rtsps_conn")
@@ -238,6 +240,7 @@ func (m *Metrics) onMetrics(ctx *gin.Context) {
anyFilterActive := pathFilter != "" || anyFilterActive := pathFilter != "" ||
hlsMuxerFilter != "" || hlsMuxerFilter != "" ||
hlsSessionFilter != "" ||
rtspConnFilter != "" || rtspConnFilter != "" ||
rtspSessionFilter != "" || rtspSessionFilter != "" ||
rtspsConnFilter != "" || rtspsConnFilter != "" ||
@@ -336,6 +339,32 @@ func (m *Metrics) onMetrics(ctx *gin.Context) {
} }
if !interfaceIsEmpty(hlsServer) { if !interfaceIsEmpty(hlsServer) {
if (typ == "" || typ == metricsTypeHLSSessions) && (!anyFilterActive || hlsSessionFilter != "") {
var data *defs.APIHLSSessionList
data, err := hlsServer.APISessionsList()
if err == nil && len(data.Items) != 0 {
out.WriteString("# HLS sessions\n")
for _, i := range data.Items {
if hlsSessionFilter == "" || hlsSessionFilter == i.ID.String() {
ta := tags(map[string]string{
"id": i.ID.String(),
"path": i.Path,
"remoteAddr": i.RemoteAddr,
})
metric(&out, "hls_sessions", ta, 1)
metric(&out, "hls_sessions_outbound_bytes", ta, int64(i.OutboundBytes))
}
}
out.WriteString("\n")
} else if hlsSessionFilter == "" {
out.WriteString("# HLS sessions\n")
metric(&out, "hls_sessions", "", 0)
metric(&out, "hls_sessions_outbound_bytes", "", 0)
out.WriteString("\n")
}
}
if (typ == "" || typ == metricsTypeHLSMuxers) && (!anyFilterActive || hlsMuxerFilter != "") { if (typ == "" || typ == metricsTypeHLSMuxers) && (!anyFilterActive || hlsMuxerFilter != "") {
var data *defs.APIHLSMuxerList var data *defs.APIHLSMuxerList
data, err := hlsServer.APIMuxersList() data, err := hlsServer.APIMuxersList()
+57
View File
@@ -89,6 +89,28 @@ func (dummyHLSServer) APIMuxersGet(string) (*defs.APIHLSMuxer, error) {
panic("unused") panic("unused")
} }
func (dummyHLSServer) APISessionsList() (*defs.APIHLSSessionList, error) {
return &defs.APIHLSSessionList{
ItemCount: 1,
PageCount: 1,
Items: []defs.APIHLSSession{{
ID: uuid.MustParse("18294761-f9d1-4ea9-9a35-fe265b62eb41"),
Created: time.Date(2003, 11, 4, 23, 15, 7, 0, time.UTC),
RemoteAddr: "124.5.5.5:34542",
Path: "mypath",
OutboundBytes: 187,
}},
}, nil
}
func (dummyHLSServer) APISessionsGet(uuid.UUID) (*defs.APIHLSSession, error) {
panic("unused")
}
func (dummyHLSServer) APISessionsKick(uuid.UUID) error {
panic("unused")
}
type dummyRTSPServer struct{} type dummyRTSPServer struct{}
func (dummyRTSPServer) APIConnsList() (*defs.APIRTSPConnsList, error) { func (dummyRTSPServer) APIConnsList() (*defs.APIRTSPConnsList, error) {
@@ -332,6 +354,18 @@ func (emptyHLSServer) APIMuxersGet(string) (*defs.APIHLSMuxer, error) {
panic("unused") panic("unused")
} }
func (emptyHLSServer) APISessionsList() (*defs.APIHLSSessionList, error) {
return &defs.APIHLSSessionList{}, nil
}
func (emptyHLSServer) APISessionsGet(uuid.UUID) (*defs.APIHLSSession, error) {
panic("unused")
}
func (emptyHLSServer) APISessionsKick(uuid.UUID) error {
panic("unused")
}
type emptyRTSPServer struct{} type emptyRTSPServer struct{}
func (emptyRTSPServer) APIConnsList() (*defs.APIRTSPConnsList, error) { func (emptyRTSPServer) APIConnsList() (*defs.APIRTSPConnsList, error) {
@@ -478,6 +512,12 @@ func TestMetrics(t *testing.T) {
"paths_bytes_received{name=\"mypath\",state=\"ready\"} 123\n"+ "paths_bytes_received{name=\"mypath\",state=\"ready\"} 123\n"+
"paths_bytes_sent{name=\"mypath\",state=\"ready\"} 456\n"+ "paths_bytes_sent{name=\"mypath\",state=\"ready\"} 456\n"+
"\n"+ "\n"+
"# HLS sessions\n"+
"hls_sessions{id=\"18294761-f9d1-4ea9-9a35-fe265b62eb41\",path=\"mypath\","+
"remoteAddr=\"124.5.5.5:34542\"} 1\n"+
"hls_sessions_outbound_bytes{id=\"18294761-f9d1-4ea9-9a35-fe265b62eb41\",path=\"mypath\","+
"remoteAddr=\"124.5.5.5:34542\"} 187\n"+
"\n"+
"# HLS muxers\n"+ "# HLS muxers\n"+
"hls_muxers{name=\"mypath\"} 1\n"+ "hls_muxers{name=\"mypath\"} 1\n"+
"hls_muxers_outbound_bytes{name=\"mypath\"} 789\n"+ "hls_muxers_outbound_bytes{name=\"mypath\"} 789\n"+
@@ -835,6 +875,10 @@ func TestZeroMetricsFallback(t *testing.T) {
"paths_bytes_sent 0\n"+ "paths_bytes_sent 0\n"+
"paths_readers 0\n"+ "paths_readers 0\n"+
"\n"+ "\n"+
"# HLS sessions\n"+
"hls_sessions 0\n"+
`hls_sessions_outbound_bytes 0`+"\n"+
"\n"+
"# HLS muxers\n"+ "# HLS muxers\n"+
"hls_muxers 0\n"+ "hls_muxers 0\n"+
"hls_muxers_outbound_bytes 0\n"+ "hls_muxers_outbound_bytes 0\n"+
@@ -965,6 +1009,7 @@ func TestFilter(t *testing.T) {
for _, ca := range []string{ for _, ca := range []string{
"path", "path",
"hls_muxer", "hls_muxer",
"hls_session",
"rtsp_conn", "rtsp_conn",
"rtsp_session", "rtsp_session",
"rtsps_conn", "rtsps_conn",
@@ -1007,6 +1052,8 @@ func TestFilter(t *testing.T) {
u += "?path=mypath" u += "?path=mypath"
case "hls_muxer": case "hls_muxer":
u += "?hls_muxer=mypath" u += "?hls_muxer=mypath"
case "hls_session":
u += "?hls_session=18294761-f9d1-4ea9-9a35-fe265b62eb41"
case "rtsp_conn": case "rtsp_conn":
u += "?rtsp_conn=18294761-f9d1-4ea9-9a35-fe265b62eb41" u += "?rtsp_conn=18294761-f9d1-4ea9-9a35-fe265b62eb41"
case "rtsp_session": case "rtsp_session":
@@ -1059,6 +1106,16 @@ func TestFilter(t *testing.T) {
`hls_muxers_bytes_sent{name="mypath"} 789`+"\n\n", `hls_muxers_bytes_sent{name="mypath"} 789`+"\n\n",
string(byts)) string(byts))
case "hls_session":
require.Equal(t,
"# HLS sessions\n"+
`hls_sessions{id="18294761-f9d1-4ea9-9a35-fe265b62eb41",path="mypath",`+
`remoteAddr="124.5.5.5:34542"} 1`+"\n"+
`hls_sessions_outbound_bytes{id="18294761-f9d1-4ea9-9a35-fe265b62eb41",path="mypath",`+
`remoteAddr="124.5.5.5:34542"} 187`+"\n"+
"\n",
string(byts))
case "rtsp_conn": case "rtsp_conn":
require.Equal(t, require.Equal(t,
"# RTSP connections\n"+ "# RTSP connections\n"+
+4 -1
View File
@@ -15,7 +15,10 @@ func isOriginAllowed(origin string, allowOrigins []string) (string, bool) {
for _, o := range allowOrigins { for _, o := range allowOrigins {
if o == "*" { if o == "*" {
return o, true if origin != "" {
return origin, true
}
return "*", true
} }
} }
@@ -38,7 +38,7 @@ func TestHandlerOrigin(t *testing.T) {
"everything allowed, with origin", "everything allowed, with origin",
"https://example.com", "https://example.com",
[]string{"*"}, []string{"*"},
"*", "https://example.com",
}, },
{ {
"allowed", "allowed",
+2 -2
View File
@@ -47,8 +47,8 @@ func (ri *recorderInstance) initialize() {
ri.format, ri.format,
) )
ri.reader = &stream.Reader{ ri.reader = &stream.Reader{
SkipBytesSent: true, SkipOutboundBytes: true,
Parent: ri, Parent: ri,
} }
ri.terminate = make(chan struct{}) ri.terminate = make(chan struct{})
+191 -44
View File
@@ -35,6 +35,12 @@ func mergePathAndQuery(path string, rawQuery string) string {
return res return res
} }
func isIOS(userAgent string) bool {
return strings.Contains(userAgent, "iPad") ||
strings.Contains(userAgent, "iPhone") ||
strings.Contains(userAgent, "iPod")
}
type httpServer struct { type httpServer struct {
address string address string
dumpPackets bool dumpPackets bool
@@ -124,6 +130,17 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
var dir string var dir string
var fname string var fname string
type contentType int
const (
index contentType = iota
multivariantPlaylist
mediaPlaylist
segment
)
var contentTyp contentType
switch { switch {
case strings.HasSuffix(pa, "/hls.min.js"): case strings.HasSuffix(pa, "/hls.min.js"):
ctx.Header("Cache-Control", "max-age=3600") ctx.Header("Cache-Control", "max-age=3600")
@@ -135,8 +152,16 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
case pa == "", pa == "favicon.ico", strings.HasSuffix(pa, "/hls.min.js.map"): case pa == "", pa == "favicon.ico", strings.HasSuffix(pa, "/hls.min.js.map"):
return return
case strings.HasSuffix(pa, ".m3u8") || case strings.HasSuffix(pa, ".m3u8"):
strings.HasSuffix(pa, ".ts") || dir, fname = gopath.Dir(pa), gopath.Base(pa)
if fname == "index.m3u8" {
contentTyp = multivariantPlaylist
} else {
contentTyp = mediaPlaylist
}
case strings.HasSuffix(pa, ".ts") ||
strings.HasSuffix(pa, ".mp4") || strings.HasSuffix(pa, ".mp4") ||
strings.HasSuffix(pa, ".mp"): strings.HasSuffix(pa, ".mp"):
dir, fname = gopath.Dir(pa), gopath.Base(pa) dir, fname = gopath.Dir(pa), gopath.Base(pa)
@@ -145,42 +170,169 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
fname += "4" fname += "4"
} }
contentTyp = segment
default: default:
dir, fname = pa, "" dir = pa
if !strings.HasSuffix(dir, "/") { if !strings.HasSuffix(dir, "/") {
ctx.Header("Location", mergePathAndQuery(ctx.Request.URL.Path+"/", ctx.Request.URL.RawQuery)) ctx.Header("Location", mergePathAndQuery(ctx.Request.URL.Path+"/", ctx.Request.URL.RawQuery))
ctx.Writer.WriteHeader(http.StatusMovedPermanently) ctx.Writer.WriteHeader(http.StatusMovedPermanently)
return return
} }
dir = dir[:len(dir)-1]
contentTyp = index
} }
dir = strings.TrimSuffix(dir, "/") switch contentTyp {
if dir == "" { case index:
return _, err := s.pathManager.FindPathConf(defs.PathFindPathConfReq{
} AccessRequest: defs.PathAccessRequest{
Name: dir,
Query: ctx.Request.URL.RawQuery,
Publish: false,
Proto: auth.ProtocolHLS,
Credentials: httpp.Credentials(ctx.Request),
IP: net.ParseIP(ctx.ClientIP()),
},
})
if err != nil {
var terr *auth.Error
if errors.As(err, &terr) {
if terr.AskCredentials {
ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`)
s.writeErrorNoLog(ctx, http.StatusUnauthorized, fmt.Errorf("authentication error"))
return
}
s.Log(logger.Info, "connection %v failed to authenticate: %v", httpp.RemoteAddr(ctx), terr.Wrapped)
// wait some seconds to delay brute force attacks
<-time.After(auth.PauseAfterError)
res, err := s.pathManager.FindPathConf(defs.PathFindPathConfReq{
AccessRequest: defs.PathAccessRequest{
Name: dir,
Query: ctx.Request.URL.RawQuery,
Publish: false,
Proto: auth.ProtocolHLS,
Credentials: httpp.Credentials(ctx.Request),
IP: net.ParseIP(ctx.ClientIP()),
},
})
if err != nil {
var terr *auth.Error
if errors.As(err, &terr) {
if terr.AskCredentials {
ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`)
s.writeErrorNoLog(ctx, http.StatusUnauthorized, fmt.Errorf("authentication error")) s.writeErrorNoLog(ctx, http.StatusUnauthorized, fmt.Errorf("authentication error"))
return return
} }
s.Log(logger.Info, "connection %v failed to authenticate: %v", httpp.RemoteAddr(ctx), terr.Wrapped) s.writeErrorNoLog(ctx, http.StatusInternalServerError, err)
return
}
ctx.Header("Cache-Control", "max-age=3600")
ctx.Header("Content-Type", "text/html")
ctx.Writer.WriteHeader(http.StatusOK)
ctx.Writer.Write(hlsIndex)
case multivariantPlaylist:
if ctx.Request.URL.Query().Get("cookieCheck") != "1" {
http.SetCookie(ctx.Writer, &http.Cookie{
Name: "cookieCheck",
Value: "1",
})
http.SetCookie(ctx.Writer, &http.Cookie{
Name: "cookieCheck",
Value: "1",
SameSite: http.SameSiteNoneMode,
Secure: true,
Partitioned: true,
HttpOnly: true,
})
q := ctx.Request.URL.Query()
q.Set("cookieCheck", "1")
ctx.Request.URL.RawQuery = q.Encode()
ctx.Writer.Header().Set("Location", mergePathAndQuery(ctx.Request.URL.Path, ctx.Request.URL.RawQuery))
ctx.Writer.WriteHeader(http.StatusFound)
return
}
if _, err := ctx.Request.Cookie("cookieCheck"); err != nil && isIOS(ctx.Request.UserAgent()) {
s.writeErrorNoLog(ctx, http.StatusBadRequest, fmt.Errorf("HLS on iOS requires the server to set and read cookies"))
return
}
q := ctx.Request.URL.Query()
q.Del("cookieCheck")
ctx.Request.URL.RawQuery = q.Encode()
sx := &session{
remoteAddr: httpp.RemoteAddr(ctx),
pathName: dir,
externalCmdPool: s.parent.ExternalCmdPool,
pathManager: s.pathManager,
server: s.parent,
}
err := sx.initialize(ctx)
if err != nil {
var terr *auth.Error
if errors.As(err, &terr) {
if terr.AskCredentials {
ctx.Header("WWW-Authenticate", `Basic realm="mediamtx"`)
s.writeErrorNoLog(ctx, http.StatusUnauthorized, fmt.Errorf("authentication error"))
return
}
s.Log(logger.Info, "connection %v failed to authenticate: %v", httpp.RemoteAddr(ctx), terr.Wrapped)
// wait some seconds to delay brute force attacks
<-time.After(auth.PauseAfterError)
s.writeErrorNoLog(ctx, http.StatusUnauthorized, fmt.Errorf("authentication error"))
return
}
var terr2 *defs.PathNoStreamAvailableError
if errors.As(err, &terr2) {
s.writeErrorNoLog(ctx, http.StatusNotFound, err)
return
}
s.writeErrorNoLog(ctx, http.StatusInternalServerError, err)
return
}
if cookie, err2 := ctx.Request.Cookie("cookieCheck"); err2 == nil && cookie.Value == "1" {
http.SetCookie(ctx.Writer, &http.Cookie{
Name: sessionCookieName,
Value: sx.secret.String(),
})
http.SetCookie(ctx.Writer, &http.Cookie{
Name: sessionCookieName,
Value: sx.secret.String(),
SameSite: http.SameSiteNoneMode,
Secure: true,
Partitioned: true,
HttpOnly: true,
})
} else {
q = ctx.Request.URL.Query()
q.Set(sessionQueryParamName, sx.secret.String())
ctx.Request.URL.RawQuery = q.Encode()
}
ctx.Writer = &responseWriterCounter{
ResponseWriter: ctx.Writer,
bytesSent: &sx.bytesSent,
}
ctx.Request.URL.Path = fname
err = sx.muxer.handleRequest(ctx)
if err != nil {
s.writeErrorNoLog(ctx, http.StatusInternalServerError, err)
return
}
default:
muxer, err := s.parent.getMuxer(serverGetMuxerReq{
path: dir,
create: false,
})
if err != nil {
// wait some seconds to delay brute force attacks // wait some seconds to delay brute force attacks
<-time.After(auth.PauseAfterError) <-time.After(auth.PauseAfterError)
@@ -188,31 +340,26 @@ func (s *httpServer) onRequest(ctx *gin.Context) {
return return
} }
s.writeErrorNoLog(ctx, http.StatusInternalServerError, err) sx := muxer.findSession(ctx)
return if sx == nil {
} // wait some seconds to delay brute force attacks
<-time.After(auth.PauseAfterError)
switch fname { s.writeErrorNoLog(ctx, http.StatusUnauthorized, fmt.Errorf("authentication error"))
case "":
ctx.Header("Cache-Control", "max-age=3600")
ctx.Header("Content-Type", "text/html")
ctx.Writer.WriteHeader(http.StatusOK)
ctx.Writer.Write(hlsIndex)
default:
var mux *muxer
mux, err = s.parent.getMuxer(serverGetMuxerReq{
path: dir,
remoteAddr: httpp.RemoteAddr(ctx),
query: ctx.Request.URL.RawQuery,
sourceOnDemand: res.Conf.SourceOnDemand,
})
if err != nil {
ctx.Writer.WriteHeader(http.StatusNotFound)
return return
} }
ctx.Writer = &responseWriterCounter{
ResponseWriter: ctx.Writer,
bytesSent: &sx.bytesSent,
}
ctx.Request.URL.Path = fname ctx.Request.URL.Path = fname
mux.handleRequest(ctx)
err = muxer.handleRequest(ctx)
if err != nil {
s.writeErrorNoLog(ctx, http.StatusInternalServerError, err)
return
}
} }
} }
+218 -89
View File
@@ -4,22 +4,22 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/bluenviron/gortsplib/v5/pkg/format"
"github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/logger" "github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/protocols/hls" "github.com/bluenviron/mediamtx/internal/protocols/hls"
"github.com/bluenviron/mediamtx/internal/stream" "github.com/bluenviron/mediamtx/internal/stream"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
const ( const (
closeCheckPeriod = 1 * time.Second recreateInstancePause = 10 * time.Second
recreatePause = 10 * time.Second
) )
func emptyTimer() *time.Timer { func emptyTimer() *time.Timer {
@@ -28,20 +28,9 @@ func emptyTimer() *time.Timer {
return t return t
} }
type responseWriterWithCounter struct { type muxerCloseInstanceReq struct {
http.ResponseWriter instance *muxerInstance
bytesSent *atomic.Uint64 err error
}
func (w *responseWriterWithCounter) Write(p []byte) (int, error) {
n, err := w.ResponseWriter.Write(p)
w.bytesSent.Add(uint64(n))
return n, err
}
type muxerGetInstanceRes struct {
instance *muxerInstance
cumulatedOutboundFramesDiscarded uint64
} }
type muxer struct { type muxer struct {
@@ -67,9 +56,12 @@ type muxer struct {
lastRequestTime atomic.Int64 lastRequestTime atomic.Int64
bytesSent atomic.Uint64 bytesSent atomic.Uint64
instanceMutex sync.RWMutex mutex sync.RWMutex
instance *muxerInstance instance *muxerInstance
cumulatedOutboundFramesDiscarded uint64 cumulatedOutboundFramesDiscarded uint64
sessionsBySecret map[uuid.UUID]*session
chCloseInstance chan muxerCloseInstanceReq
} }
func (m *muxer) initialize() { func (m *muxer) initialize() {
@@ -79,7 +71,8 @@ func (m *muxer) initialize() {
m.ctxCancel = ctxCancel m.ctxCancel = ctxCancel
m.created = time.Now() m.created = time.Now()
m.lastRequestTime.Store(time.Now().UnixNano()) m.lastRequestTime.Store(time.Now().UnixNano())
m.bytesSent.Store(0) m.sessionsBySecret = make(map[uuid.UUID]*session)
m.chCloseInstance = make(chan muxerCloseInstanceReq)
m.Log(logger.Info, "created %s", func() string { m.Log(logger.Info, "created %s", func() string {
if m.remoteAddr == "" { if m.remoteAddr == "" {
@@ -89,7 +82,7 @@ func (m *muxer) initialize() {
}()) }())
// block first request to getInstance() until the first instance is available // block first request to getInstance() until the first instance is available
m.instanceMutex.Lock() m.mutex.Lock()
m.wg.Add(1) m.wg.Add(1)
go m.run() go m.run()
@@ -116,9 +109,23 @@ func (m *muxer) run() {
m.ctxCancel() m.ctxCancel()
m.parent.closeMuxer(m) if m.instance != nil {
m.instance.close()
}
m.mutex.Lock()
m.instance = nil
for _, sx := range m.sessionsBySecret {
sx.close2(fmt.Errorf("muxer destroyed"))
}
m.mutex.Unlock()
m.Log(logger.Info, "destroyed: %v", err) m.Log(logger.Info, "destroyed: %v", err)
m.parent.closeMuxer(m)
} }
func (m *muxer) runInner() error { func (m *muxer) runInner() error {
@@ -131,7 +138,7 @@ func (m *muxer) runInner() error {
}, },
}) })
if err != nil { if err != nil {
m.instanceMutex.Unlock() m.mutex.Unlock()
return err return err
} }
@@ -142,71 +149,101 @@ func (m *muxer) runInner() error {
tmp, err := m.createInstance(res.Stream) tmp, err := m.createInstance(res.Stream)
if err != nil { if err != nil {
if m.remoteAddr != "" || errors.Is(err, hls.ErrNoSupportedCodecs) { if m.remoteAddr != "" || errors.Is(err, hls.ErrNoSupportedCodecs) {
m.instanceMutex.Unlock() m.mutex.Unlock()
return err return err
} }
m.Log(logger.Error, err.Error()) m.Log(logger.Error, "muxer instance crashed: %v", err)
} }
m.instance = tmp m.instance = tmp
m.instanceMutex.Unlock() m.mutex.Unlock()
defer func() { var recreateInstanceTimer *time.Timer
if m.instance != nil {
m.closeInstance()
}
}()
var instanceError chan error
var recreateTimer *time.Timer
if m.instance != nil { if m.instance != nil {
instanceError = m.instance.errorChan() recreateInstanceTimer = emptyTimer()
recreateTimer = emptyTimer()
} else { } else {
instanceError = make(chan error) recreateInstanceTimer = time.NewTimer(recreateInstancePause)
recreateTimer = time.NewTimer(recreatePause)
} }
defer func() {
recreateInstanceTimer.Stop()
}()
sessionCleanupTicker := time.NewTicker(sessionCleanupPeriod)
defer sessionCleanupTicker.Stop()
var activityCheckTimer *time.Timer var activityCheckTimer *time.Timer
if m.remoteAddr != "" { if m.remoteAddr != "" {
activityCheckTimer = time.NewTimer(closeCheckPeriod) activityCheckTimer = time.NewTimer(max(time.Duration(m.closeAfter)/3, 1*time.Second))
} else { } else {
activityCheckTimer = emptyTimer() activityCheckTimer = emptyTimer()
} }
defer func() {
activityCheckTimer.Stop()
}()
for { for {
select { select {
case err = <-instanceError: case req := <-m.chCloseInstance:
if m.remoteAddr != "" { if m.instance != req.instance {
return err continue
} }
m.Log(logger.Error, err.Error()) m.mutex.Lock()
m.closeInstance() m.cumulatedOutboundFramesDiscarded += m.instance.reader.OutboundFramesDiscarded()
instanceError = make(chan error) m.instance = nil
recreateTimer = time.NewTimer(recreatePause) m.mutex.Unlock()
case <-recreateTimer.C: if m.remoteAddr != "" {
return req.err
} else {
m.mutex.Lock()
for _, sx := range m.sessionsBySecret {
sx.close2(fmt.Errorf("muxer instance crashed"))
}
m.sessionsBySecret = make(map[uuid.UUID]*session)
m.mutex.Unlock()
m.Log(logger.Error, "muxer instance crashed: %v", req.err)
}
recreateInstanceTimer = time.NewTimer(recreateInstancePause)
case <-recreateInstanceTimer.C:
tmp, err = m.createInstance(res.Stream) tmp, err = m.createInstance(res.Stream)
if err != nil { if err != nil {
m.Log(logger.Error, err.Error()) m.Log(logger.Error, "muxer instance crashed: %v", err)
recreateTimer = time.NewTimer(recreatePause) recreateInstanceTimer = time.NewTimer(recreateInstancePause)
} else { continue
m.instanceMutex.Lock()
m.instance = tmp
m.instanceMutex.Unlock()
instanceError = m.instance.errorChan()
} }
m.mutex.Lock()
m.instance = tmp
m.mutex.Unlock()
case <-sessionCleanupTicker.C:
now := time.Now()
m.mutex.Lock()
for secret, sx := range m.sessionsBySecret {
lastRequest := time.Unix(0, sx.lastRequestTime.Load())
if now.Sub(lastRequest) >= sessionCloseAfter {
delete(m.sessionsBySecret, secret)
sx.close2(fmt.Errorf("inactive"))
}
}
m.mutex.Unlock()
case <-activityCheckTimer.C: case <-activityCheckTimer.C:
t := time.Unix(0, m.lastRequestTime.Load()) t := time.Unix(0, m.lastRequestTime.Load())
if time.Since(t) >= time.Duration(m.closeAfter) { if time.Since(t) >= time.Duration(m.closeAfter) {
return fmt.Errorf("not used anymore") return fmt.Errorf("not used anymore")
} }
activityCheckTimer = time.NewTimer(closeCheckPeriod) activityCheckTimer = time.NewTimer(max(time.Duration(m.closeAfter)/3, 1*time.Second))
case <-m.ctx.Done(): case <-m.ctx.Done():
return errors.New("terminated") return errors.New("terminated")
@@ -214,16 +251,6 @@ func (m *muxer) runInner() error {
} }
} }
func (m *muxer) closeInstance() {
m.instanceMutex.Lock()
m.cumulatedOutboundFramesDiscarded += m.instance.reader.OutboundFramesDiscarded()
var tmp *muxerInstance
tmp, m.instance = m.instance, nil
m.instanceMutex.Unlock()
tmp.close()
}
func (m *muxer) createInstance(strm *stream.Stream) (*muxerInstance, error) { func (m *muxer) createInstance(strm *stream.Stream) (*muxerInstance, error) {
mi := &muxerInstance{ mi := &muxerInstance{
variant: m.variant, variant: m.variant,
@@ -233,54 +260,107 @@ func (m *muxer) createInstance(strm *stream.Stream) (*muxerInstance, error) {
segmentMaxSize: m.segmentMaxSize, segmentMaxSize: m.segmentMaxSize,
directory: m.directory, directory: m.directory,
pathName: m.pathName, pathName: m.pathName,
bytesSent: &m.bytesSent,
wg: m.wg,
stream: strm, stream: strm,
server: m.parent,
parent: m, parent: m,
} }
err := mi.initialize() err := mi.initialize()
return mi, err if err != nil {
return nil, err
}
return mi, nil
} }
func (m *muxer) getInstance() muxerGetInstanceRes { func (m *muxer) closeInstance(mi *muxerInstance, err error) {
m.instanceMutex.RLock() select {
defer m.instanceMutex.RUnlock() case m.chCloseInstance <- muxerCloseInstanceReq{instance: mi, err: err}:
case <-m.ctx.Done():
return muxerGetInstanceRes{
instance: m.instance,
cumulatedOutboundFramesDiscarded: m.cumulatedOutboundFramesDiscarded,
} }
} }
// APIReaderDescribe implements reader. // APIReaderDescribe implements reader.
func (m *muxer) APIReaderDescribe() *defs.APIPathReader { func (m *muxer) APIReaderDescribe() *defs.APIPathReader {
return &defs.APIPathReader{ return &defs.APIPathReader{
Type: defs.APIPathReaderTypeHLSMuxer, Type: defs.APIPathReaderTypeHidden,
ID: "", ID: "",
} }
} }
func (m *muxer) handleRequest(ctx *gin.Context) { func (m *muxer) addSession(sx *session) ([]format.Format, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
select {
case <-m.ctx.Done():
return nil, fmt.Errorf("terminated")
default:
}
if m.instance == nil {
return nil, fmt.Errorf("muxer instance not available")
}
m.sessionsBySecret[sx.secret] = sx
return m.instance.reader.Formats(), nil
}
func (m *muxer) findSession(ctx *gin.Context) *session {
var rawSecret string
if cookie, err := ctx.Request.Cookie(sessionCookieName); err == nil {
rawSecret = cookie.Value
} else {
q := ctx.Request.URL.Query()
rawSecret = q.Get(sessionQueryParamName)
}
secret, err := uuid.Parse(rawSecret)
if err != nil {
return nil
}
m.mutex.RLock()
defer m.mutex.RUnlock()
sx, ok := m.sessionsBySecret[secret]
if !ok {
return nil
}
if ctx.ClientIP() != sx.ip {
return nil
}
sx.lastRequestTime.Store(time.Now().UnixNano())
return sx
}
func (m *muxer) handleRequest(ctx *gin.Context) error {
m.lastRequestTime.Store(time.Now().UnixNano()) m.lastRequestTime.Store(time.Now().UnixNano())
res := m.getInstance() m.mutex.RLock()
if res.instance == nil { instance := m.instance
ctx.Writer.WriteHeader(http.StatusNotFound) m.mutex.RUnlock()
return
if instance == nil {
return fmt.Errorf("muxer instance not available")
} }
w := &responseWriterWithCounter{ instance.handleRequest(ctx)
ResponseWriter: ctx.Writer, return nil
bytesSent: &m.bytesSent,
}
res.instance.handleRequest(w, ctx.Request)
} }
func (m *muxer) apiItem() *defs.APIHLSMuxer { func (m *muxer) apiItem() *defs.APIHLSMuxer {
res := m.getInstance() m.mutex.RLock()
instance := m.instance
cumulatedOutboundFramesDiscarded := m.cumulatedOutboundFramesDiscarded
m.mutex.RUnlock()
outboundFramesDiscarded := res.cumulatedOutboundFramesDiscarded outboundFramesDiscarded := cumulatedOutboundFramesDiscarded
if res.instance != nil { if instance != nil {
outboundFramesDiscarded += res.instance.reader.OutboundFramesDiscarded() outboundFramesDiscarded += instance.reader.OutboundFramesDiscarded()
} }
return &defs.APIHLSMuxer{ return &defs.APIHLSMuxer{
@@ -292,3 +372,52 @@ func (m *muxer) apiItem() *defs.APIHLSMuxer {
BytesSent: m.bytesSent.Load(), BytesSent: m.bytesSent.Load(),
} }
} }
func (m *muxer) apiSessionsList() []defs.APIHLSSession {
m.mutex.RLock()
defer m.mutex.RUnlock()
sessions := make([]defs.APIHLSSession, 0, len(m.sessionsBySecret))
for _, sx := range m.sessionsBySecret {
sessions = append(sessions, *sx.apiItem())
}
return sessions
}
func (m *muxer) findSessionByUUID(uuid uuid.UUID) *session {
for _, sx := range m.sessionsBySecret {
if sx.uuid == uuid {
return sx
}
}
return nil
}
func (m *muxer) apiSessionsGet(uuid uuid.UUID) (*defs.APIHLSSession, bool) {
m.mutex.RLock()
sx := m.findSessionByUUID(uuid)
if sx == nil {
m.mutex.RUnlock()
return nil, false
}
m.mutex.RUnlock()
return sx.apiItem(), true
}
func (m *muxer) apiSessionsKick(uuid uuid.UUID) bool {
m.mutex.Lock()
defer m.mutex.Unlock()
sx := m.findSessionByUUID(uuid)
if sx == nil {
return false
}
sx.close2(fmt.Errorf("kicked"))
delete(m.sessionsBySecret, sx.secret)
return true
}
+71 -10
View File
@@ -1,9 +1,12 @@
package hls package hls
import ( import (
"net/http" "context"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"sync/atomic"
"time" "time"
"github.com/bluenviron/gohlslib/v2" "github.com/bluenviron/gohlslib/v2"
@@ -12,8 +15,21 @@ import (
"github.com/bluenviron/mediamtx/internal/logger" "github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/protocols/hls" "github.com/bluenviron/mediamtx/internal/protocols/hls"
"github.com/bluenviron/mediamtx/internal/stream" "github.com/bluenviron/mediamtx/internal/stream"
"github.com/gin-gonic/gin"
) )
const (
sessionCookieName = "hlsSession"
sessionQueryParamName = "session"
sessionCloseAfter = 30 * time.Second
sessionCleanupPeriod = sessionCloseAfter / 3
)
type instanceParent interface {
logger.Writer
closeInstance(*muxerInstance, error)
}
type muxerInstance struct { type muxerInstance struct {
variant conf.HLSVariant variant conf.HLSVariant
segmentCount int segmentCount int
@@ -22,14 +38,21 @@ type muxerInstance struct {
segmentMaxSize conf.StringSize segmentMaxSize conf.StringSize
directory string directory string
pathName string pathName string
bytesSent *atomic.Uint64
wg *sync.WaitGroup
stream *stream.Stream stream *stream.Stream
parent logger.Writer server logger.Writer
parent instanceParent
hmuxer *gohlslib.Muxer ctx context.Context
reader *stream.Reader ctxCancel func()
hmuxer *gohlslib.Muxer
reader *stream.Reader
} }
func (mi *muxerInstance) initialize() error { func (mi *muxerInstance) initialize() error {
mi.Log(logger.Debug, "instance created")
var muxerDirectory string var muxerDirectory string
if mi.directory != "" { if mi.directory != "" {
muxerDirectory = filepath.Join(mi.directory, mi.pathName) muxerDirectory = filepath.Join(mi.directory, mi.pathName)
@@ -49,8 +72,8 @@ func (mi *muxerInstance) initialize() error {
} }
mi.reader = &stream.Reader{ mi.reader = &stream.Reader{
SkipBytesSent: true, SkipOutboundBytes: true,
Parent: mi, Parent: mi,
} }
err := hls.FromStream(mi.stream.Desc, mi.reader, mi.hmuxer) err := hls.FromStream(mi.stream.Desc, mi.reader, mi.hmuxer)
@@ -68,6 +91,11 @@ func (mi *muxerInstance) initialize() error {
mi.stream.AddReader(mi.reader) mi.stream.AddReader(mi.reader)
mi.ctx, mi.ctxCancel = context.WithCancel(context.Background())
mi.wg.Add(1)
go mi.run()
return nil return nil
} }
@@ -77,17 +105,50 @@ func (mi *muxerInstance) Log(level logger.Level, format string, args ...any) {
} }
func (mi *muxerInstance) close() { func (mi *muxerInstance) close() {
mi.ctxCancel()
}
func (mi *muxerInstance) run() {
defer mi.wg.Done()
err := mi.runInner()
mi.ctxCancel()
mi.stream.RemoveReader(mi.reader) mi.stream.RemoveReader(mi.reader)
mi.hmuxer.Close() mi.hmuxer.Close()
if mi.hmuxer.Directory != "" { if mi.hmuxer.Directory != "" {
os.Remove(mi.hmuxer.Directory) os.Remove(mi.hmuxer.Directory)
} }
mi.Log(logger.Debug, "instance destroyed: %v", err)
mi.parent.closeInstance(mi, err)
} }
func (mi *muxerInstance) errorChan() chan error { func (mi *muxerInstance) runInner() error {
return mi.reader.Error() for {
select {
case <-mi.ctx.Done():
return fmt.Errorf("terminated")
case err := <-mi.reader.Error():
return err
}
}
} }
func (mi *muxerInstance) handleRequest(w http.ResponseWriter, r *http.Request) { func (mi *muxerInstance) handleRequest(ctx *gin.Context) {
mi.hmuxer.Handle(w, r) w := ctx.Writer
w = &responseWriterNoCache{ResponseWriter: w}
w = &responseWriterCounter{
ResponseWriter: w,
bytesSent: mi.bytesSent,
}
mi.hmuxer.Handle(w, ctx.Request)
} }
@@ -0,0 +1,18 @@
package hls
import (
"sync/atomic"
"github.com/gin-gonic/gin"
)
type responseWriterCounter struct {
gin.ResponseWriter
bytesSent *atomic.Uint64
}
func (w *responseWriterCounter) Write(p []byte) (int, error) {
n, err := w.ResponseWriter.Write(p)
w.bytesSent.Add(uint64(n))
return n, err
}
@@ -0,0 +1,19 @@
package hls
import (
"net/http"
"github.com/gin-gonic/gin"
)
type responseWriterNoCache struct {
gin.ResponseWriter
}
func (w *responseWriterNoCache) WriteHeader(statusCode int) {
if statusCode == http.StatusOK {
w.ResponseWriter.Header().Set("Cache-Control", "private, no-cache")
}
w.ResponseWriter.WriteHeader(statusCode)
}
+139 -9
View File
@@ -11,12 +11,17 @@ import (
"github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs" "github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
"github.com/bluenviron/mediamtx/internal/logger" "github.com/bluenviron/mediamtx/internal/logger"
"github.com/google/uuid"
) )
// ErrMuxerNotFound is returned when a muxer is not found. // ErrMuxerNotFound is returned when a muxer is not found.
var ErrMuxerNotFound = errors.New("muxer not found") var ErrMuxerNotFound = errors.New("muxer not found")
// ErrSessionNotFound is returned when a session is not found.
var ErrSessionNotFound = errors.New("session not found")
func interfaceIsEmpty(i any) bool { func interfaceIsEmpty(i any) bool {
return reflect.ValueOf(i).Kind() != reflect.Pointer || reflect.ValueOf(i).IsNil() return reflect.ValueOf(i).Kind() != reflect.Pointer || reflect.ValueOf(i).IsNil()
} }
@@ -28,9 +33,10 @@ type serverGetMuxerRes struct {
type serverGetMuxerReq struct { type serverGetMuxerReq struct {
path string path string
remoteAddr string create bool
query string remoteAddr string // only if create == true
sourceOnDemand bool query string // only if create == true
sourceOnDemand bool // only if create == true
res chan serverGetMuxerRes res chan serverGetMuxerRes
} }
@@ -53,6 +59,34 @@ type serverAPIMuxersGetReq struct {
res chan serverAPIMuxersGetRes res chan serverAPIMuxersGetRes
} }
type serverAPISessionsListRes struct {
data *defs.APIHLSSessionList
err error
}
type serverAPISessionsListReq struct {
res chan serverAPISessionsListRes
}
type serverAPISessionsGetRes struct {
data *defs.APIHLSSession
err error
}
type serverAPISessionsGetReq struct {
uuid uuid.UUID
res chan serverAPISessionsGetRes
}
type serverAPISessionsKickRes struct {
err error
}
type serverAPISessionsKickReq struct {
uuid uuid.UUID
res chan serverAPISessionsKickRes
}
type serverMetrics interface { type serverMetrics interface {
SetHLSServer(defs.APIHLSServer) SetHLSServer(defs.APIHLSServer)
} }
@@ -86,6 +120,7 @@ type Server struct {
ReadTimeout conf.Duration ReadTimeout conf.Duration
WriteTimeout conf.Duration WriteTimeout conf.Duration
MuxerCloseAfter conf.Duration MuxerCloseAfter conf.Duration
ExternalCmdPool *externalcmd.Pool
Metrics serverMetrics Metrics serverMetrics
PathManager serverPathManager PathManager serverPathManager
Parent serverParent Parent serverParent
@@ -97,12 +132,15 @@ type Server struct {
muxers map[string]*muxer muxers map[string]*muxer
// in // in
chPathReady chan defs.Path chPathReady chan defs.Path
chPathNotReady chan defs.Path chPathNotReady chan defs.Path
chGetMuxer chan serverGetMuxerReq chGetMuxer chan serverGetMuxerReq
chCloseMuxer chan *muxer chCloseMuxer chan *muxer
chAPIMuxerList chan serverAPIMuxersListReq chAPIMuxerList chan serverAPIMuxersListReq
chAPIMuxerGet chan serverAPIMuxersGetReq chAPIMuxerGet chan serverAPIMuxersGetReq
chAPISessionsList chan serverAPISessionsListReq
chAPISessionsGet chan serverAPISessionsGetReq
chAPISessionsKick chan serverAPISessionsKickReq
} }
// Initialize initializes the server. // Initialize initializes the server.
@@ -118,6 +156,9 @@ func (s *Server) Initialize() error {
s.chCloseMuxer = make(chan *muxer) s.chCloseMuxer = make(chan *muxer)
s.chAPIMuxerList = make(chan serverAPIMuxersListReq) s.chAPIMuxerList = make(chan serverAPIMuxersListReq)
s.chAPIMuxerGet = make(chan serverAPIMuxersGetReq) s.chAPIMuxerGet = make(chan serverAPIMuxersGetReq)
s.chAPISessionsList = make(chan serverAPISessionsListReq)
s.chAPISessionsGet = make(chan serverAPISessionsGetReq)
s.chAPISessionsKick = make(chan serverAPISessionsKickReq)
s.httpServer = &httpServer{ s.httpServer = &httpServer{
address: s.Address, address: s.Address,
@@ -211,6 +252,8 @@ outer:
switch { switch {
case ok: case ok:
req.res <- serverGetMuxerRes{muxer: mux} req.res <- serverGetMuxerRes{muxer: mux}
case !req.create:
req.res <- serverGetMuxerRes{err: fmt.Errorf("muxer not found")}
case s.AlwaysRemux && !req.sourceOnDemand: case s.AlwaysRemux && !req.sourceOnDemand:
req.res <- serverGetMuxerRes{err: fmt.Errorf("muxer is waiting to be created")} req.res <- serverGetMuxerRes{err: fmt.Errorf("muxer is waiting to be created")}
default: default:
@@ -248,6 +291,43 @@ outer:
req.res <- serverAPIMuxersGetRes{data: muxer.apiItem()} req.res <- serverAPIMuxersGetRes{data: muxer.apiItem()}
case req := <-s.chAPISessionsList:
data := &defs.APIHLSSessionList{
Items: []defs.APIHLSSession{},
}
for _, muxer := range s.muxers {
data.Items = append(data.Items, muxer.apiSessionsList()...)
}
sort.Slice(data.Items, func(i, j int) bool {
return data.Items[i].Created.Before(data.Items[j].Created)
})
req.res <- serverAPISessionsListRes{data: data}
case req := <-s.chAPISessionsGet:
for _, muxer := range s.muxers {
session, ok := muxer.apiSessionsGet(req.uuid)
if ok {
req.res <- serverAPISessionsGetRes{data: session}
continue outer
}
}
req.res <- serverAPISessionsGetRes{err: ErrSessionNotFound}
case req := <-s.chAPISessionsKick:
for _, muxer := range s.muxers {
ok := muxer.apiSessionsKick(req.uuid)
if ok {
req.res <- serverAPISessionsKickRes{}
continue outer
}
}
req.res <- serverAPISessionsKickRes{err: ErrSessionNotFound}
case <-s.ctx.Done(): case <-s.ctx.Done():
break outer break outer
} }
@@ -349,3 +429,53 @@ func (s *Server) APIMuxersGet(name string) (*defs.APIHLSMuxer, error) {
return nil, fmt.Errorf("terminated") return nil, fmt.Errorf("terminated")
} }
} }
// APISessionsList implements defs.APIHLSServer.
func (s *Server) APISessionsList() (*defs.APIHLSSessionList, error) {
req := serverAPISessionsListReq{
res: make(chan serverAPISessionsListRes),
}
select {
case s.chAPISessionsList <- req:
res := <-req.res
return res.data, res.err
case <-s.ctx.Done():
return nil, fmt.Errorf("terminated")
}
}
// APISessionsGet implements defs.APIHLSServer.
func (s *Server) APISessionsGet(uuid uuid.UUID) (*defs.APIHLSSession, error) {
req := serverAPISessionsGetReq{
uuid: uuid,
res: make(chan serverAPISessionsGetRes),
}
select {
case s.chAPISessionsGet <- req:
res := <-req.res
return res.data, res.err
case <-s.ctx.Done():
return nil, fmt.Errorf("terminated")
}
}
// APISessionsKick implements defs.APIHLSServer.
func (s *Server) APISessionsKick(uuid uuid.UUID) error {
req := serverAPISessionsKickReq{
uuid: uuid,
res: make(chan serverAPISessionsKickRes),
}
select {
case s.chAPISessionsKick <- req:
res := <-req.res
return res.err
case <-s.ctx.Done():
return fmt.Errorf("terminated")
}
}
+147 -5
View File
@@ -13,6 +13,7 @@ import (
"github.com/bluenviron/gohlslib/v2" "github.com/bluenviron/gohlslib/v2"
"github.com/bluenviron/gohlslib/v2/pkg/codecs" "github.com/bluenviron/gohlslib/v2/pkg/codecs"
"github.com/bluenviron/gortsplib/v5/pkg/description" "github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/format"
"github.com/bluenviron/mediacommon/v2/pkg/codecs/mpeg4audio" "github.com/bluenviron/mediacommon/v2/pkg/codecs/mpeg4audio"
"github.com/bluenviron/mediamtx/internal/auth" "github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/conf"
@@ -158,7 +159,7 @@ func TestServerNotFound(t *testing.T) {
}, },
addReaderImpl: func(req defs.PathAddReaderReq) (*defs.PathAddReaderRes, error) { addReaderImpl: func(req defs.PathAddReaderReq) (*defs.PathAddReaderRes, error) {
require.Equal(t, "nonexisting", req.AccessRequest.Name) require.Equal(t, "nonexisting", req.AccessRequest.Name)
return nil, fmt.Errorf("not found") return nil, &defs.PathNoStreamAvailableError{}
}, },
} }
@@ -253,11 +254,22 @@ func TestServerRead(t *testing.T) {
}, },
addReaderImpl: func(req defs.PathAddReaderReq) (*defs.PathAddReaderRes, error) { addReaderImpl: func(req defs.PathAddReaderReq) (*defs.PathAddReaderRes, error) {
require.Equal(t, "teststream", req.AccessRequest.Name) require.Equal(t, "teststream", req.AccessRequest.Name)
if ca == "always remux off" {
switch req.Author.(type) {
case (*muxer):
if ca == "always remux off" {
require.Equal(t, "param=value", req.AccessRequest.Query)
} else {
require.Equal(t, "", req.AccessRequest.Query)
}
case *session:
require.Equal(t, "param=value", req.AccessRequest.Query) require.Equal(t, "param=value", req.AccessRequest.Query)
} else {
require.Equal(t, "", req.AccessRequest.Query) default:
t.Errorf("should not happen")
} }
return &defs.PathAddReaderRes{Path: &dummyPath{}, Stream: strm}, nil return &defs.PathAddReaderRes{Path: &dummyPath{}, Stream: strm}, nil
}, },
} }
@@ -579,7 +591,7 @@ func TestAuthError(t *testing.T) {
ReadTimeout: conf.Duration(10 * time.Second), ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second), WriteTimeout: conf.Duration(10 * time.Second),
PathManager: &dummyPathManager{ PathManager: &dummyPathManager{
findPathConfImpl: func(req defs.PathFindPathConfReq) (*defs.PathFindPathConfRes, error) { addReaderImpl: func(req defs.PathAddReaderReq) (*defs.PathAddReaderRes, error) {
if req.AccessRequest.Credentials.User == "" && req.AccessRequest.Credentials.Pass == "" { if req.AccessRequest.Credentials.User == "" && req.AccessRequest.Credentials.Pass == "" {
return nil, &auth.Error{AskCredentials: true, Wrapped: fmt.Errorf("auth error")} return nil, &auth.Error{AskCredentials: true, Wrapped: fmt.Errorf("auth error")}
} }
@@ -625,3 +637,133 @@ func TestAuthError(t *testing.T) {
require.Equal(t, 2, n) require.Equal(t, 2, n)
} }
func TestAuthQueryPreservedAcrossRedirect(t *testing.T) {
s := &Server{
Address: "127.0.0.1:8888",
Encryption: false,
ServerKey: "",
ServerCert: "",
AlwaysRemux: true,
Variant: conf.HLSVariant(gohlslib.MuxerVariantMPEGTS),
SegmentCount: 7,
SegmentDuration: conf.Duration(1 * time.Second),
PartDuration: conf.Duration(200 * time.Millisecond),
SegmentMaxSize: 50 * 1024 * 1024,
ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second),
PathManager: &dummyPathManager{
findPathConfImpl: func(_ defs.PathFindPathConfReq) (*defs.PathFindPathConfRes, error) {
return nil, &auth.Error{AskCredentials: true, Wrapped: fmt.Errorf("auth error")}
},
},
Parent: test.NilLogger,
}
err := s.Initialize()
require.NoError(t, err)
defer s.Close()
client := &http.Client{
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
res, err := client.Get("http://127.0.0.1:8888/stream/index.m3u8?jwt=mytoken")
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusFound, res.StatusCode)
require.Equal(t, "/stream/index.m3u8?cookieCheck=1&jwt=mytoken", res.Header.Get("Location"))
}
func TestServerNoSupportedCodecs(t *testing.T) {
for _, ca := range []string{
"always remux off",
"always remux on",
} {
t.Run(ca, func(t *testing.T) {
desc := &description.Session{Medias: []*description.Media{{
Type: description.MediaTypeVideo,
Formats: []format.Format{&format.VP8{}},
}}}
strm := &stream.Stream{
Desc: desc,
WriteQueueSize: 512,
RTPMaxPayloadSize: 1450,
Parent: test.NilLogger,
}
err := strm.Initialize()
require.NoError(t, err)
pm := &dummyPathManager{
findPathConfImpl: func(req defs.PathFindPathConfReq) (*defs.PathFindPathConfRes, error) {
require.Equal(t, "teststream", req.AccessRequest.Name)
return &defs.PathFindPathConfRes{Conf: &conf.Path{}, User: req.AccessRequest.Credentials.User}, nil
},
addReaderImpl: func(req defs.PathAddReaderReq) (*defs.PathAddReaderRes, error) {
require.Equal(t, "teststream", req.AccessRequest.Name)
return &defs.PathAddReaderRes{Path: &dummyPath{}, Stream: strm}, nil
},
}
s := &Server{
Address: "127.0.0.1:8888",
AlwaysRemux: (ca == "always remux on"),
Variant: conf.HLSVariant(gohlslib.MuxerVariantMPEGTS),
SegmentCount: 7,
SegmentDuration: conf.Duration(1 * time.Second),
PartDuration: conf.Duration(200 * time.Millisecond),
SegmentMaxSize: 50 * 1024 * 1024,
TrustedProxies: conf.IPNetworks{},
ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second),
PathManager: pm,
Parent: test.NilLogger,
}
err = s.Initialize()
require.NoError(t, err)
defer s.Close()
client := &http.Client{
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
res, err := client.Get("http://127.0.0.1:8888/teststream/index.m3u8")
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusFound, res.StatusCode)
require.Equal(t, "/teststream/index.m3u8?cookieCheck=1", res.Header.Get("Location"))
res, err = client.Get("http://127.0.0.1:8888/teststream/index.m3u8?cookieCheck=1")
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusInternalServerError, res.StatusCode)
require.Contains(t, res.Header.Get("Content-Type"), "application/json")
byts, err := io.ReadAll(res.Body)
require.NoError(t, err)
var payload defs.APIError
err = json.Unmarshal(byts, &payload)
require.NoError(t, err)
if ca == "always remux off" {
require.Equal(t, defs.APIError{
Status: defs.APIErrorStatusError,
Error: "terminated",
}, payload)
} else {
require.Equal(t, defs.APIError{
Status: defs.APIErrorStatusError,
Error: "muxer is waiting to be created",
}, payload)
}
})
}
}
+168
View File
@@ -0,0 +1,168 @@
package hls
import (
"encoding/hex"
"net"
"slices"
"sync/atomic"
"time"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/externalcmd"
"github.com/bluenviron/mediamtx/internal/hooks"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/protocols/httpp"
"github.com/bluenviron/mediamtx/internal/stream"
"github.com/bluenviron/mediamtx/internal/unit"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type sessionServer interface {
logger.Writer
getMuxer(serverGetMuxerReq) (*muxer, error)
}
type session struct {
remoteAddr string
pathName string
externalCmdPool *externalcmd.Pool
pathManager serverPathManager
server sessionServer
uuid uuid.UUID
secret uuid.UUID
ip string
created time.Time
query string
user string
lastRequestTime atomic.Int64
bytesSent atomic.Uint64
path defs.Path
stream *stream.Stream
muxer *muxer
reader *stream.Reader
onUnreadHook func()
}
func (s *session) initialize(ctx *gin.Context) error {
s.uuid = uuid.New()
s.secret = uuid.New()
s.ip, _, _ = net.SplitHostPort(s.remoteAddr)
s.created = time.Now()
s.query = ctx.Request.URL.RawQuery
s.lastRequestTime.Store(time.Now().UnixNano())
res, err := s.pathManager.AddReader(defs.PathAddReaderReq{
Author: s,
AccessRequest: defs.PathAccessRequest{
Name: s.pathName,
Query: s.query,
Publish: false,
Proto: auth.ProtocolHLS,
ID: &s.uuid,
Credentials: httpp.Credentials(ctx.Request),
IP: net.ParseIP(ctx.ClientIP()),
},
})
if err != nil {
return err
}
s.path = res.Path
s.stream = res.Stream
s.user = res.User
muxer, err := s.server.getMuxer(serverGetMuxerReq{
path: s.pathName,
create: true,
remoteAddr: s.remoteAddr,
query: s.query,
sourceOnDemand: res.Path.SafeConf().SourceOnDemand,
})
if err != nil {
s.path.RemoveReader(defs.PathRemoveReaderReq{Author: s})
return err
}
s.muxer = muxer
muxerFormats, err := s.muxer.addSession(s)
if err != nil {
s.path.RemoveReader(defs.PathRemoveReaderReq{Author: s})
return err
}
s.reader = &stream.Reader{
Parent: s,
}
// all of this is needed to allow Stream to increase outbound bytes for every HLS session
for _, medi := range res.Stream.Desc.Medias {
for _, forma := range medi.Formats {
if slices.Contains(muxerFormats, forma) {
s.reader.OnData(medi, forma, func(_ *unit.Unit) error {
return nil
})
}
}
}
res.Stream.AddReader(s.reader)
s.Log(logger.Info, "created by %s, reading from muxer '%s'", s.remoteAddr, s.pathName)
s.onUnreadHook = hooks.OnRead(hooks.OnReadParams{
Logger: s,
ExternalCmdPool: s.externalCmdPool,
Conf: res.Path.SafeConf(),
ExternalCmdEnv: res.Path.ExternalCmdEnv(),
Reader: *s.APIReaderDescribe(),
Query: s.query,
})
return nil
}
// called by path or path manager.
// not implemented since closing the Muxer is enough to close every associated session.
func (s *session) Close() {
}
func (s *session) close2(err error) {
s.stream.RemoveReader(s.reader)
s.path.RemoveReader(defs.PathRemoveReaderReq{Author: s})
s.onUnreadHook()
s.Log(logger.Info, "closed: %v", err)
}
// Log implements logger.Writer.
func (s *session) Log(level logger.Level, format string, args ...any) {
id := hex.EncodeToString(s.uuid[:4])
s.server.Log(level, "[session %v] "+format, append([]any{id}, args...)...)
}
func (s *session) apiItem() *defs.APIHLSSession {
outboundBytes := s.bytesSent.Load()
return &defs.APIHLSSession{
ID: s.uuid,
Created: s.created,
RemoteAddr: s.remoteAddr,
Path: s.pathName,
Query: s.query,
User: s.user,
OutboundBytes: outboundBytes,
}
}
func (s *session) APIReaderDescribe() *defs.APIPathReader {
return &defs.APIPathReader{
Type: defs.APIPathReaderTypeHLSSession,
ID: s.uuid.String(),
}
}
+1 -1
View File
@@ -170,7 +170,7 @@ func (c *conn) onDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx,
return res, nil, err2 return res, nil, err2
} }
var terr2 defs.PathNoStreamAvailableError var terr2 *defs.PathNoStreamAvailableError
if errors.As(res.Err, &terr2) { if errors.As(res.Err, &terr2) {
return &base.Response{ return &base.Response{
StatusCode: base.StatusNotFound, StatusCode: base.StatusNotFound,
+1 -1
View File
@@ -287,7 +287,7 @@ func (s *session) onSetup(c *conn, ctx *gortsplib.ServerHandlerOnSetupCtx,
return res, nil, err2 return res, nil, err2
} }
var terr2 defs.PathNoStreamAvailableError var terr2 *defs.PathNoStreamAvailableError
if errors.As(err, &terr2) { if errors.As(err, &terr2) {
return &base.Response{ return &base.Response{
StatusCode: base.StatusNotFound, StatusCode: base.StatusNotFound,
+1 -1
View File
@@ -710,7 +710,7 @@ func TestServerReadNotFound(t *testing.T) {
return &defs.PathFindPathConfRes{Conf: &conf.Path{}, User: req.AccessRequest.Credentials.User}, nil return &defs.PathFindPathConfRes{Conf: &conf.Path{}, User: req.AccessRequest.Credentials.User}, nil
}, },
AddReaderImpl: func(_ defs.PathAddReaderReq) (*defs.PathAddReaderRes, error) { AddReaderImpl: func(_ defs.PathAddReaderReq) (*defs.PathAddReaderRes, error) {
return nil, defs.PathNoStreamAvailableError{} return nil, &defs.PathNoStreamAvailableError{}
}, },
} }
+10 -12
View File
@@ -279,21 +279,19 @@ func (s *session) runPublish() (int, error) {
func (s *session) runRead() (int, error) { func (s *session) runRead() (int, error) {
ip, _, _ := net.SplitHostPort(s.req.remoteAddr) ip, _, _ := net.SplitHostPort(s.req.remoteAddr)
req := defs.PathAccessRequest{
Name: s.req.pathName,
Query: s.req.httpRequest.URL.RawQuery,
Proto: auth.ProtocolWebRTC,
ID: &s.uuid,
Credentials: httpp.Credentials(s.req.httpRequest),
IP: net.ParseIP(ip),
}
res, err := s.pathManager.AddReader(defs.PathAddReaderReq{ res, err := s.pathManager.AddReader(defs.PathAddReaderReq{
Author: s, Author: s,
AccessRequest: req, AccessRequest: defs.PathAccessRequest{
Name: s.req.pathName,
Query: s.req.httpRequest.URL.RawQuery,
Proto: auth.ProtocolWebRTC,
ID: &s.uuid,
Credentials: httpp.Credentials(s.req.httpRequest),
IP: net.ParseIP(ip),
},
}) })
if err != nil { if err != nil {
var terr2 defs.PathNoStreamAvailableError var terr2 *defs.PathNoStreamAvailableError
if errors.As(err, &terr2) { if errors.As(err, &terr2) {
return http.StatusNotFound, err return http.StatusNotFound, err
} }
+2 -2
View File
@@ -95,7 +95,7 @@ func (r *secondaryReader) Close() {
// APIReaderDescribe implements reader. // APIReaderDescribe implements reader.
func (*secondaryReader) APIReaderDescribe() *defs.APIPathReader { func (*secondaryReader) APIReaderDescribe() *defs.APIPathReader {
return &defs.APIPathReader{ return &defs.APIPathReader{
Type: defs.APIPathReaderTypeRPICameraSecondary, Type: defs.APIPathReaderTypeHidden,
ID: "", ID: "",
} }
} }
@@ -341,7 +341,7 @@ func (s *Source) waitForPrimary(
}, },
}) })
if err != nil { if err != nil {
var err2 defs.PathNoStreamAvailableError var err2 *defs.PathNoStreamAvailableError
if errors.As(err, &err2) { if errors.As(err, &err2) {
select { select {
case <-time.After(pauseBetweenErrors): case <-time.After(pauseBetweenErrors):
+2 -2
View File
@@ -16,8 +16,8 @@ type OnDataFunc func(*unit.Unit) error
// Reader is a stream reader. // Reader is a stream reader.
type Reader struct { type Reader struct {
SkipBytesSent bool SkipOutboundBytes bool
Parent logger.Writer Parent logger.Writer
onDatas map[*description.Media]map[format.Format]OnDataFunc onDatas map[*description.Media]map[format.Format]OnDataFunc
queueSize int queueSize int
+2 -10
View File
@@ -394,8 +394,8 @@ func (s *Stream) Initialize() error {
alwaysAvailable: s.AlwaysAvailable, alwaysAvailable: s.AlwaysAvailable,
rtpMaxPayloadSize: s.RTPMaxPayloadSize, rtpMaxPayloadSize: s.RTPMaxPayloadSize,
replaceNTP: s.ReplaceNTP, replaceNTP: s.ReplaceNTP,
addInboundBytes: s.addInboundBytes, inboundBytes: &s.inboundBytes,
addOutboundBytes: s.addOutboundBytes, outboundBytes: &s.outboundBytes,
updateLastTime: s.updateLastTime, updateLastTime: s.updateLastTime,
writeRTSP: s.writeRTSP, writeRTSP: s.writeRTSP,
inboundFramesInError: s.inboundFramesInError, inboundFramesInError: s.inboundFramesInError,
@@ -574,14 +574,6 @@ func (s *Stream) WaitForReaders() {
<-s.hasReaders <-s.hasReaders
} }
func (s *Stream) addInboundBytes(v uint64) {
s.inboundBytes.Add(v)
}
func (s *Stream) addOutboundBytes(v uint64) {
s.outboundBytes.Add(v)
}
func (s *Stream) updateLastTime(pts time.Duration) { func (s *Stream) updateLastTime(pts time.Duration) {
s.timeMutex.Lock() s.timeMutex.Lock()
defer s.timeMutex.Unlock() defer s.timeMutex.Unlock()
+3 -2
View File
@@ -2,6 +2,7 @@ package stream
import ( import (
"crypto/rand" "crypto/rand"
"sync/atomic"
"time" "time"
"github.com/bluenviron/gortsplib/v5/pkg/description" "github.com/bluenviron/gortsplib/v5/pkg/description"
@@ -44,8 +45,8 @@ type streamFormat struct {
rtpMaxPayloadSize int rtpMaxPayloadSize int
replaceNTP bool replaceNTP bool
inboundFramesInError *errordumper.Dumper inboundFramesInError *errordumper.Dumper
addInboundBytes func(uint64) inboundBytes *atomic.Uint64
addOutboundBytes func(uint64) outboundBytes *atomic.Uint64
updateLastTime func(time.Duration) updateLastTime func(time.Duration)
writeRTSP func(*description.Media, []*rtp.Packet, time.Time) writeRTSP func(*description.Media, []*rtp.Packet, time.Time)
parent logger.Writer parent logger.Writer
+5 -4
View File
@@ -1,6 +1,7 @@
package stream package stream
import ( import (
"sync/atomic"
"time" "time"
"github.com/bluenviron/gortsplib/v5/pkg/description" "github.com/bluenviron/gortsplib/v5/pkg/description"
@@ -15,8 +16,8 @@ type streamMedia struct {
alwaysAvailable bool alwaysAvailable bool
rtpMaxPayloadSize int rtpMaxPayloadSize int
replaceNTP bool replaceNTP bool
addInboundBytes func(uint64) inboundBytes *atomic.Uint64
addOutboundBytes func(uint64) outboundBytes *atomic.Uint64
updateLastTime func(time.Duration) updateLastTime func(time.Duration)
writeRTSP func(*description.Media, []*rtp.Packet, time.Time) writeRTSP func(*description.Media, []*rtp.Packet, time.Time)
inboundFramesInError *errordumper.Dumper inboundFramesInError *errordumper.Dumper
@@ -36,8 +37,8 @@ func (sm *streamMedia) initialize() error {
rtpMaxPayloadSize: sm.rtpMaxPayloadSize, rtpMaxPayloadSize: sm.rtpMaxPayloadSize,
replaceNTP: sm.replaceNTP, replaceNTP: sm.replaceNTP,
inboundFramesInError: sm.inboundFramesInError, inboundFramesInError: sm.inboundFramesInError,
addInboundBytes: sm.addInboundBytes, inboundBytes: sm.inboundBytes,
addOutboundBytes: sm.addOutboundBytes, outboundBytes: sm.outboundBytes,
updateLastTime: sm.updateLastTime, updateLastTime: sm.updateLastTime,
writeRTSP: sm.writeRTSP, writeRTSP: sm.writeRTSP,
parent: sm.parent, parent: sm.parent,
+2 -2
View File
@@ -69,7 +69,7 @@ func TestStream(t *testing.T) {
require.Equal(t, uint64(14), strm.OutboundBytes()) require.Equal(t, uint64(14), strm.OutboundBytes())
} }
func TestStreamSkipBytesSent(t *testing.T) { func TestStreamSkipOutboundBytes(t *testing.T) {
desc := &description.Session{Medias: []*description.Media{ desc := &description.Session{Medias: []*description.Media{
{ {
Type: description.MediaTypeVideo, Type: description.MediaTypeVideo,
@@ -98,7 +98,7 @@ func TestStreamSkipBytesSent(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
r := &Reader{ r := &Reader{
SkipBytesSent: true, SkipOutboundBytes: true,
} }
recv := make(chan struct{}) recv := make(chan struct{})
+3 -3
View File
@@ -170,7 +170,7 @@ func (ssf *subStreamFormat) writeUnitInner(u *unit.Unit) error {
} }
size := unitSize(u) size := unitSize(u)
ssf.streamFormat.addInboundBytes(size) ssf.streamFormat.inboundBytes.Add(size)
ssf.streamFormat.writeRTSP(ssf.streamFormat.media, u.RTPPackets, u.NTP) ssf.streamFormat.writeRTSP(ssf.streamFormat.media, u.RTPPackets, u.NTP)
@@ -178,8 +178,8 @@ func (ssf *subStreamFormat) writeUnitInner(u *unit.Unit) error {
csr := sr csr := sr
cOnData := onData cOnData := onData
sr.push(func() error { sr.push(func() error {
if !csr.SkipBytesSent { if !csr.SkipOutboundBytes {
ssf.streamFormat.addOutboundBytes(size) ssf.streamFormat.outboundBytes.Add(size)
} }
return cOnData(u) return cOnData(u)
}) })