Ready-to-use SRT / WebRTC / RTSP / RTMP / LL-HLS media server and media proxy that allows to read, publish, proxy, record and playback video and audio streams.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

325 lines
7.1 KiB

package hls
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/aler9/gortsplib/v2/pkg/format"
gm3u8 "github.com/grafov/m3u8"
"github.com/aler9/rtsp-simple-server/internal/hls/m3u8"
"github.com/aler9/rtsp-simple-server/internal/logger"
)
func clientDownloadPlaylist(ctx context.Context, httpClient *http.Client, ur *url.URL) (m3u8.Playlist, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ur.String(), nil)
if err != nil {
return nil, err
}
res, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("bad status code: %d", res.StatusCode)
}
byts, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
return m3u8.Unmarshal(byts)
}
func allCodecsAreSupported(codecs string) bool {
for _, codec := range strings.Split(codecs, ",") {
if !strings.HasPrefix(codec, "avc1") &&
!strings.HasPrefix(codec, "mp4a") {
return false
}
}
return true
}
func pickLeadingPlaylist(variants []*gm3u8.Variant) *gm3u8.Variant {
var candidates []*gm3u8.Variant //nolint:prealloc
for _, v := range variants {
if v.Codecs != "" && !allCodecsAreSupported(v.Codecs) {
continue
}
candidates = append(candidates, v)
}
if candidates == nil {
return nil
}
// pick the variant with the greatest bandwidth
var leadingPlaylist *gm3u8.Variant
for _, v := range candidates {
if leadingPlaylist == nil ||
v.VariantParams.Bandwidth > leadingPlaylist.VariantParams.Bandwidth {
leadingPlaylist = v
}
}
return leadingPlaylist
}
func pickAudioPlaylist(alternatives []*gm3u8.Alternative, groupID string) *gm3u8.Alternative {
candidates := func() []*gm3u8.Alternative {
var ret []*gm3u8.Alternative
for _, alt := range alternatives {
if alt.GroupId == groupID {
ret = append(ret, alt)
}
}
return ret
}()
if candidates == nil {
return nil
}
// pick the default audio playlist
for _, alt := range candidates {
if alt.Default {
return alt
}
}
// alternatively, pick the first one
return candidates[0]
}
type clientTimeSync interface{}
type clientDownloaderPrimary struct {
primaryPlaylistURL *url.URL
logger ClientLogger
onTracks func(*format.H264, *format.MPEG4Audio) error
onVideoData func(time.Duration, [][]byte)
onAudioData func(time.Duration, []byte)
rp *clientRoutinePool
httpClient *http.Client
leadingTimeSync clientTimeSync
// in
streamFormats chan []format.Format
// out
startStreaming chan struct{}
leadingTimeSyncReady chan struct{}
}
func newClientDownloaderPrimary(
primaryPlaylistURL *url.URL,
fingerprint string,
logger ClientLogger,
rp *clientRoutinePool,
onTracks func(*format.H264, *format.MPEG4Audio) error,
onVideoData func(time.Duration, [][]byte),
onAudioData func(time.Duration, []byte),
) *clientDownloaderPrimary {
var tlsConfig *tls.Config
if fingerprint != "" {
tlsConfig = &tls.Config{
InsecureSkipVerify: true,
VerifyConnection: func(cs tls.ConnectionState) error {
h := sha256.New()
h.Write(cs.PeerCertificates[0].Raw)
hstr := hex.EncodeToString(h.Sum(nil))
fingerprintLower := strings.ToLower(fingerprint)
if hstr != fingerprintLower {
return fmt.Errorf("server fingerprint do not match: expected %s, got %s",
fingerprintLower, hstr)
}
return nil
},
}
}
return &clientDownloaderPrimary{
primaryPlaylistURL: primaryPlaylistURL,
logger: logger,
onTracks: onTracks,
onVideoData: onVideoData,
onAudioData: onAudioData,
rp: rp,
httpClient: &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
},
streamFormats: make(chan []format.Format),
startStreaming: make(chan struct{}),
leadingTimeSyncReady: make(chan struct{}),
}
}
func (d *clientDownloaderPrimary) run(ctx context.Context) error {
d.logger.Log(logger.Debug, "downloading primary playlist %s", d.primaryPlaylistURL)
pl, err := clientDownloadPlaylist(ctx, d.httpClient, d.primaryPlaylistURL)
if err != nil {
return err
}
streamCount := 0
switch plt := pl.(type) {
case *m3u8.MediaPlaylist:
d.logger.Log(logger.Debug, "primary playlist is a stream playlist")
ds := newClientDownloaderStream(
true,
d.httpClient,
d.primaryPlaylistURL,
plt,
d.logger,
d.rp,
d.onStreamFormats,
d.onSetLeadingTimeSync,
d.onGetLeadingTimeSync,
d.onVideoData,
d.onAudioData)
d.rp.add(ds)
streamCount++
case *m3u8.MasterPlaylist:
leadingPlaylist := pickLeadingPlaylist(plt.Variants)
if leadingPlaylist == nil {
return fmt.Errorf("no variants with supported codecs found")
}
u, err := clientAbsoluteURL(d.primaryPlaylistURL, leadingPlaylist.URI)
if err != nil {
return err
}
ds := newClientDownloaderStream(
true,
d.httpClient,
u,
nil,
d.logger,
d.rp,
d.onStreamFormats,
d.onSetLeadingTimeSync,
d.onGetLeadingTimeSync,
d.onVideoData,
d.onAudioData)
d.rp.add(ds)
streamCount++
if leadingPlaylist.Audio != "" {
audioPlaylist := pickAudioPlaylist(plt.Alternatives, leadingPlaylist.Audio)
if audioPlaylist == nil {
return fmt.Errorf("audio playlist with id \"%s\" not found", leadingPlaylist.Audio)
}
u, err := clientAbsoluteURL(d.primaryPlaylistURL, audioPlaylist.URI)
if err != nil {
return err
}
ds := newClientDownloaderStream(
false,
d.httpClient,
u,
nil,
d.logger,
d.rp,
d.onStreamFormats,
d.onSetLeadingTimeSync,
d.onGetLeadingTimeSync,
d.onVideoData,
d.onAudioData)
d.rp.add(ds)
streamCount++
}
default:
return fmt.Errorf("invalid playlist")
}
var tracks []format.Format
for i := 0; i < streamCount; i++ {
select {
case streamFormats := <-d.streamFormats:
tracks = append(tracks, streamFormats...)
case <-ctx.Done():
return fmt.Errorf("terminated")
}
}
var videoTrack *format.H264
var audioTrack *format.MPEG4Audio
for _, track := range tracks {
switch ttrack := track.(type) {
case *format.H264:
if videoTrack != nil {
return fmt.Errorf("multiple video tracks are not supported")
}
videoTrack = ttrack
case *format.MPEG4Audio:
if audioTrack != nil {
return fmt.Errorf("multiple audio tracks are not supported")
}
audioTrack = ttrack
}
}
err = d.onTracks(videoTrack, audioTrack)
if err != nil {
return err
}
close(d.startStreaming)
return nil
}
func (d *clientDownloaderPrimary) onStreamFormats(ctx context.Context, tracks []format.Format) bool {
select {
case d.streamFormats <- tracks:
case <-ctx.Done():
return false
}
select {
case <-d.startStreaming:
case <-ctx.Done():
return false
}
return true
}
func (d *clientDownloaderPrimary) onSetLeadingTimeSync(ts clientTimeSync) {
d.leadingTimeSync = ts
close(d.leadingTimeSyncReady)
}
func (d *clientDownloaderPrimary) onGetLeadingTimeSync(ctx context.Context) (clientTimeSync, bool) {
select {
case <-d.leadingTimeSyncReady:
case <-ctx.Done():
return nil, false
}
return d.leadingTimeSync, true
}