Browse Source
- Persist segments - Record configurations - Rebuild entire stream playlists - First steps to working towards https://github.com/owncast/owncast/issues/102pull/3395/head
37 changed files with 1448 additions and 109 deletions
@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
|
||||
package controllers |
||||
|
||||
import ( |
||||
"net/http" |
||||
"strings" |
||||
|
||||
"github.com/owncast/owncast/replays" |
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
// GetReplays will return a list of all available replays.
|
||||
func GetReplays(w http.ResponseWriter, r *http.Request) { |
||||
streams, err := replays.GetStreams() |
||||
if err != nil { |
||||
log.Errorln(err) |
||||
w.WriteHeader(http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
WriteResponse(w, streams) |
||||
} |
||||
|
||||
// GetReplay will return a playable content for a given stream Id.
|
||||
func GetReplay(w http.ResponseWriter, r *http.Request) { |
||||
pathComponents := strings.Split(r.URL.Path, "/") |
||||
if len(pathComponents) == 3 { |
||||
// Return the master playlist for the requested stream
|
||||
streamId := pathComponents[2] |
||||
getReplayMasterPlaylist(streamId, w) |
||||
return |
||||
} else if len(pathComponents) == 4 { |
||||
// Return the media playlist for the requested stream and output config
|
||||
streamId := pathComponents[2] |
||||
outputConfigId := pathComponents[3] |
||||
getReplayMediaPlaylist(streamId, outputConfigId, w) |
||||
return |
||||
} |
||||
|
||||
BadRequestHandler(w, nil) |
||||
} |
||||
|
||||
// getReplayMasterPlaylist will return a complete replay of a stream as a HLS playlist.
|
||||
// /api/replay/{streamId}.
|
||||
func getReplayMasterPlaylist(streamId string, w http.ResponseWriter) { |
||||
playlistGenerator := replays.NewPlaylistGenerator() |
||||
playlist, err := playlistGenerator.GenerateMasterPlaylistForStream(streamId) |
||||
if err != nil { |
||||
log.Println(err) |
||||
} |
||||
|
||||
if playlist == nil { |
||||
w.WriteHeader(http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
w.Header().Add("Content-Type", "application/x-mpegURL") |
||||
if _, err := w.Write(playlist.Encode().Bytes()); err != nil { |
||||
log.Errorln(err) |
||||
return |
||||
} |
||||
} |
||||
|
||||
// getReplayMediaPlaylist will return a media playlist for a given stream.
|
||||
// /api/replay/{streamId}/{outputConfigId}.
|
||||
func getReplayMediaPlaylist(streamId, outputConfigId string, w http.ResponseWriter) { |
||||
playlistGenerator := replays.NewPlaylistGenerator() |
||||
playlist, err := playlistGenerator.GenerateMediaPlaylistForStreamAndConfiguration(streamId, outputConfigId) |
||||
if err != nil { |
||||
w.WriteHeader(http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
w.Header().Add("Content-Type", "application/x-mpegURL") |
||||
if _, err := w.Write(playlist.Encode().Bytes()); err != nil { |
||||
log.Errorln(err) |
||||
return |
||||
} |
||||
} |
||||
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
package data |
||||
|
||||
import "database/sql" |
||||
|
||||
func createRecordingTables(db *sql.DB) { |
||||
createSegmentsTableSQL := `CREATE TABLE IF NOT EXISTS video_segments ( |
||||
"id" string NOT NULL, |
||||
"stream_id" string NOT NULL, |
||||
"output_configuration_id" string NOT NULL, |
||||
"path" TEXT NOT NULL, |
||||
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL, |
||||
PRIMARY KEY (id) |
||||
);CREATE INDEX video_segments_stream_id ON video_segments (stream_id);CREATE INDEX video_segments_stream_id_timestamp ON video_segments (stream_id,timestamp);` |
||||
|
||||
createVideoOutputConfigsTableSQL := `CREATE TABLE IF NOT EXISTS video_segment_output_configuration ( |
||||
"id" string NOT NULL, |
||||
"variant_id" string NOT NULL, |
||||
"name" string NOT NULL, |
||||
"stream_id" string NOT NULL, |
||||
"segment_duration" INTEGER NOT NULL, |
||||
"bitrate" INTEGER NOT NULL, |
||||
"framerate" INTEGER NOT NULL, |
||||
"resolution_width" INTEGER, |
||||
"resolution_height" INTEGER, |
||||
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL, |
||||
PRIMARY KEY (id) |
||||
);CREATE INDEX video_segment_output_configuration_stream_id ON video_segment_output_configuration (stream_id);` |
||||
|
||||
createVideoStreamsTableSQL := `CREATE TABLE IF NOT EXISTS streams ( |
||||
"id" string NOT NULL, |
||||
"stream_title" TEXT, |
||||
"start_time" DATE NOT NULL, |
||||
"end_time" DATE, |
||||
PRIMARY KEY (id) |
||||
); |
||||
CREATE INDEX streams_id ON streams (id); |
||||
CREATE INDEX streams_start_time ON streams (start_time); |
||||
CREATE INDEX streams_start_end_time ON streams (start_time,end_time); |
||||
` |
||||
|
||||
MustExec(createSegmentsTableSQL, db) |
||||
MustExec(createVideoOutputConfigsTableSQL, db) |
||||
MustExec(createVideoStreamsTableSQL, db) |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
package core |
||||
|
||||
import ( |
||||
"io" |
||||
|
||||
"github.com/owncast/owncast/core/transcoder" |
||||
) |
||||
|
||||
func setupVideoComponentsForId(streamId string) { |
||||
} |
||||
|
||||
func setupLiveTranscoderForId(streamId string, rtmpOut *io.PipeReader) { |
||||
_storage.SetStreamId(streamId) |
||||
handler.SetStreamId(streamId) |
||||
|
||||
go func() { |
||||
_transcoder = transcoder.NewTranscoder(streamId) |
||||
_transcoder.TranscoderCompleted = func(error) { |
||||
SetStreamAsDisconnected() |
||||
_transcoder = nil |
||||
_currentBroadcast = nil |
||||
} |
||||
_transcoder.SetStdin(rtmpOut) |
||||
_transcoder.Start(true) |
||||
}() |
||||
} |
||||
@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"context" |
||||
"database/sql" |
||||
"strconv" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/owncast/owncast/core/data" |
||||
"github.com/owncast/owncast/db" |
||||
"github.com/owncast/owncast/utils" |
||||
"github.com/teris-io/shortid" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
type HLSRecorder struct { |
||||
streamID string |
||||
startTime time.Time |
||||
|
||||
// The video variant configurations that were used for this stream.
|
||||
outputConfigurations []HLSOutputConfiguration |
||||
|
||||
datastore *data.Datastore |
||||
} |
||||
|
||||
// NewRecording returns a new instance of the HLS recorder.
|
||||
func NewRecording(streamID string) *HLSRecorder { |
||||
// We don't support replaying offline clips.
|
||||
if streamID == "offline" { |
||||
return nil |
||||
} |
||||
|
||||
h := HLSRecorder{ |
||||
streamID: streamID, |
||||
startTime: time.Now(), |
||||
datastore: data.GetDatastore(), |
||||
} |
||||
|
||||
outputs := data.GetStreamOutputVariants() |
||||
latency := data.GetStreamLatencyLevel() |
||||
|
||||
streamTitle := data.GetStreamTitle() |
||||
validTitle := streamTitle != "" |
||||
|
||||
if err := h.datastore.GetQueries().InsertStream(context.Background(), db.InsertStreamParams{ |
||||
ID: streamID, |
||||
StartTime: h.startTime, |
||||
StreamTitle: sql.NullString{String: streamTitle, Valid: validTitle}, |
||||
}); err != nil { |
||||
log.Panicln(err) |
||||
} |
||||
|
||||
// Create a reference of the output configurations that were used for this stream.
|
||||
for variantId, o := range outputs { |
||||
configId := shortid.MustGenerate() |
||||
|
||||
if err := h.datastore.GetQueries().InsertOutputConfiguration(context.Background(), db.InsertOutputConfigurationParams{ |
||||
ID: configId, |
||||
Name: o.Name, |
||||
StreamID: streamID, |
||||
VariantID: strconv.Itoa(variantId), |
||||
SegmentDuration: int32(latency.SecondsPerSegment), |
||||
Bitrate: int32(o.VideoBitrate), |
||||
Framerate: int32(o.Framerate), |
||||
ResolutionWidth: sql.NullInt32{Int32: int32(o.ScaledWidth), Valid: true}, |
||||
ResolutionHeight: sql.NullInt32{Int32: int32(o.ScaledHeight), Valid: true}, |
||||
}); err != nil { |
||||
log.Panicln(err) |
||||
} |
||||
|
||||
h.outputConfigurations = append(h.outputConfigurations, HLSOutputConfiguration{ |
||||
ID: configId, |
||||
Name: o.Name, |
||||
VideoBitrate: o.VideoBitrate, |
||||
ScaledWidth: o.ScaledWidth, |
||||
ScaledHeight: o.ScaledHeight, |
||||
Framerate: o.Framerate, |
||||
SegmentDuration: float64(latency.SegmentCount), |
||||
}) |
||||
} |
||||
return &h |
||||
} |
||||
|
||||
// SetOutputConfigurations sets the output configurations for this stream.
|
||||
func (h *HLSRecorder) SetOutputConfigurations(configs []HLSOutputConfiguration) { |
||||
h.outputConfigurations = configs |
||||
} |
||||
|
||||
// StreamBegan is called when a stream is started.
|
||||
func (h *HLSRecorder) StreamBegan(id string) { |
||||
h.streamID = id |
||||
h.startTime = time.Now() |
||||
} |
||||
|
||||
// SegmentWritten is called when a segment is written to disk.
|
||||
func (h *HLSRecorder) SegmentWritten(path string) { |
||||
outputConfigurationIndexString := utils.GetIndexFromFilePath(path) |
||||
outputConfigurationIndex, err := strconv.Atoi(outputConfigurationIndexString) |
||||
if err != nil { |
||||
log.Errorln("HLSRecorder segmentWritten error:", err) |
||||
return |
||||
} |
||||
|
||||
p := strings.ReplaceAll(path, "data/", "") |
||||
|
||||
segment := HLSSegment{ |
||||
ID: shortid.MustGenerate(), |
||||
StreamID: h.streamID, |
||||
Path: p, |
||||
} |
||||
|
||||
if err := h.datastore.GetQueries().InsertSegment(context.Background(), db.InsertSegmentParams{ |
||||
ID: segment.ID, |
||||
StreamID: segment.StreamID, |
||||
OutputConfigurationID: h.outputConfigurations[outputConfigurationIndex].ID, |
||||
Path: segment.Path, |
||||
}); err != nil { |
||||
log.Errorln(err) |
||||
} |
||||
} |
||||
|
||||
// StreamEnded is called when a stream is ended so the end time can be noted
|
||||
// in the stream's metadata.
|
||||
func (h *HLSRecorder) StreamEnded() { |
||||
if err := h.datastore.GetQueries().SetStreamEnded(context.Background(), h.streamID); err != nil { |
||||
log.Errorln(err) |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
package replays |
||||
|
||||
import "time" |
||||
|
||||
// HLSSegment represents a single HLS segment.
|
||||
type HLSSegment struct { |
||||
ID string |
||||
StreamID string |
||||
Timestamp time.Time |
||||
OutputConfigurationID string |
||||
Path string |
||||
} |
||||
@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"bytes" |
||||
"fmt" |
||||
|
||||
"github.com/grafov/m3u8" |
||||
) |
||||
|
||||
// MediaPlaylistAllowCacheTag is a custom tag to explicitly state that this
|
||||
// playlist is allowed to be cached.
|
||||
type MediaPlaylistAllowCacheTag struct { |
||||
Type string |
||||
} |
||||
|
||||
// TagName should return the full tag identifier including the leading
|
||||
// '#' and trailing ':' if the tag also contains a value or attribute
|
||||
// list.
|
||||
func (tag *MediaPlaylistAllowCacheTag) TagName() string { |
||||
return "#EXT-X-ALLOW-CACHE" |
||||
} |
||||
|
||||
// Decode decodes the input line. The line will be the entire matched
|
||||
// line, including the identifier.
|
||||
func (tag *MediaPlaylistAllowCacheTag) Decode(line string) (m3u8.CustomTag, error) { |
||||
_, err := fmt.Sscanf(line, "#EXT-X-ALLOW-CACHE") |
||||
|
||||
return tag, err |
||||
} |
||||
|
||||
// SegmentTag specifies that this tag is not for segments.
|
||||
func (tag *MediaPlaylistAllowCacheTag) SegmentTag() bool { |
||||
return false |
||||
} |
||||
|
||||
// Encode formats the structure to the text result.
|
||||
func (tag *MediaPlaylistAllowCacheTag) Encode() *bytes.Buffer { |
||||
buf := new(bytes.Buffer) |
||||
|
||||
buf.WriteString(tag.TagName()) |
||||
buf.WriteString(tag.Type) |
||||
|
||||
return buf |
||||
} |
||||
|
||||
// String implements Stringer interface.
|
||||
func (tag *MediaPlaylistAllowCacheTag) String() string { |
||||
return tag.Encode().String() |
||||
} |
||||
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
package replays |
||||
|
||||
type HLSOutputConfiguration struct { |
||||
ID string |
||||
StreamId string |
||||
VariantId string |
||||
Name string |
||||
VideoBitrate int |
||||
ScaledWidth int |
||||
ScaledHeight int |
||||
Framerate int |
||||
SegmentDuration float64 |
||||
} |
||||
@ -0,0 +1,283 @@
@@ -0,0 +1,283 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/grafov/m3u8" |
||||
"github.com/owncast/owncast/core/data" |
||||
"github.com/owncast/owncast/db" |
||||
"github.com/pkg/errors" |
||||
) |
||||
|
||||
/* |
||||
The PlaylistGenerator is responsible for creating the master and media |
||||
playlists, in order to replay a stream in whole, or part. It requires detailed |
||||
metadata about how the initial live stream was configured, as well as a |
||||
access to every segment that was created during the live stream. |
||||
*/ |
||||
|
||||
type PlaylistGenerator struct { |
||||
datastore *data.Datastore |
||||
} |
||||
|
||||
func NewPlaylistGenerator() *PlaylistGenerator { |
||||
return &PlaylistGenerator{ |
||||
datastore: data.GetDatastore(), |
||||
} |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) GenerateMasterPlaylistForStream(streamId string) (*m3u8.MasterPlaylist, error) { |
||||
// stream, err := p.GetStream(streamId)
|
||||
// if err != nil {
|
||||
// return nil, errors.Wrap(err, "failed to get stream")
|
||||
// }
|
||||
|
||||
// Determine the different output configurations for this stream.
|
||||
configs, err := p.GetConfigurationsForStream(streamId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get configurations for stream") |
||||
} |
||||
|
||||
// Create the master playlist that will hold the different media playlists.
|
||||
masterPlaylist := p.createNewMasterPlaylist() |
||||
|
||||
// Create the media playlists for each output configuration.
|
||||
for _, config := range configs { |
||||
// Verify the validity of the configuration.
|
||||
if config.VideoBitrate == 0 { |
||||
return nil, errors.New("video bitrate is unavailable") |
||||
} |
||||
|
||||
if config.Framerate == 0 { |
||||
return nil, errors.New("video framerate is unavailable") |
||||
} |
||||
|
||||
mediaPlaylist, err := p.GenerateMediaPlaylistForStreamAndConfiguration(streamId, config.ID) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to create media playlist") |
||||
} |
||||
|
||||
// Append the media playlist to the master playlist.
|
||||
params := m3u8.VariantParams{ |
||||
ProgramId: 1, |
||||
Name: config.Name, |
||||
FrameRate: float64(config.Framerate), |
||||
Bandwidth: uint32(config.VideoBitrate * 1000), |
||||
// Match what is generated in our live playlists.
|
||||
Codecs: "avc1.64001f,mp4a.40.2", |
||||
} |
||||
|
||||
// If both the width and height are set then we can set that as
|
||||
// the resolution in the media playlist.
|
||||
if config.ScaledHeight > 0 && config.ScaledWidth > 0 { |
||||
params.Resolution = fmt.Sprintf("%dx%d", config.ScaledWidth, config.ScaledHeight) |
||||
} |
||||
|
||||
// Add the media playlist to the master playlist.
|
||||
publicPlaylistPath := strings.Join([]string{"/replay", streamId, config.ID}, "/") |
||||
masterPlaylist.Append(publicPlaylistPath, mediaPlaylist, params) |
||||
} |
||||
|
||||
// Return the final master playlist that contains all the media playlists.
|
||||
return masterPlaylist, nil |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) GenerateMediaPlaylistForStreamAndConfiguration(streamId, outputConfigurationId string) (*m3u8.MediaPlaylist, error) { |
||||
stream, err := p.GetStream(streamId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get stream") |
||||
} |
||||
|
||||
config, err := p.GetOutputConfig(outputConfigurationId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get output configuration") |
||||
} |
||||
|
||||
// Fetch all the segments for this configuration.
|
||||
segments, err := p.GetAllSegmentsForOutputConfiguration(outputConfigurationId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get all segments for output configuration") |
||||
} |
||||
|
||||
// Create the media playlist for this configuration and add the segments.
|
||||
mediaPlaylist, err := p.createMediaPlaylistForConfigurationAndSegments(config, stream.StartTime, stream.InProgress, segments) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to create media playlist") |
||||
} |
||||
|
||||
return mediaPlaylist, nil |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) GetStream(streamId string) (*Stream, error) { |
||||
stream, err := p.datastore.GetQueries().GetStreamById(context.Background(), streamId) |
||||
if stream.ID == "" { |
||||
return nil, errors.Wrap(err, "failed to get stream") |
||||
} |
||||
|
||||
s := Stream{ |
||||
ID: stream.ID, |
||||
Title: stream.StreamTitle.String, |
||||
StartTime: stream.StartTime, |
||||
EndTime: stream.EndTime.Time, |
||||
InProgress: !stream.EndTime.Valid, |
||||
} |
||||
|
||||
return &s, nil |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) GetOutputConfig(outputConfigId string) (*HLSOutputConfiguration, error) { |
||||
config, err := p.datastore.GetQueries().GetOutputConfigurationForId(context.Background(), outputConfigId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get output configuration") |
||||
} |
||||
|
||||
return createConfigFromConfigRow(config), nil |
||||
} |
||||
|
||||
// GetConfigurationsForStream returns the output configurations for a given stream.
|
||||
func (p *PlaylistGenerator) GetConfigurationsForStream(streamId string) ([]*HLSOutputConfiguration, error) { |
||||
outputConfigRows, err := p.datastore.GetQueries().GetOutputConfigurationsForStreamId(context.Background(), streamId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get output configurations for stream") |
||||
} |
||||
|
||||
outputConfigs := []*HLSOutputConfiguration{} |
||||
for _, row := range outputConfigRows { |
||||
config := &HLSOutputConfiguration{ |
||||
ID: row.ID, |
||||
StreamId: streamId, |
||||
VariantId: row.VariantID, |
||||
Name: row.Name, |
||||
VideoBitrate: int(row.Bitrate), |
||||
Framerate: int(row.Framerate), |
||||
ScaledHeight: int(row.ResolutionWidth.Int32), |
||||
ScaledWidth: int(row.ResolutionHeight.Int32), |
||||
SegmentDuration: float64(row.SegmentDuration), |
||||
} |
||||
outputConfigs = append(outputConfigs, config) |
||||
} |
||||
|
||||
return outputConfigs, nil |
||||
} |
||||
|
||||
// GetAllSegmentsForOutputConfiguration returns all the segments for a given output config.
|
||||
func (p *PlaylistGenerator) GetAllSegmentsForOutputConfiguration(outputId string) ([]HLSSegment, error) { |
||||
segmentRows, err := p.datastore.GetQueries().GetSegmentsForOutputId(context.Background(), outputId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get segments for output config") |
||||
} |
||||
|
||||
segments := []HLSSegment{} |
||||
for _, row := range segmentRows { |
||||
segment := HLSSegment{ |
||||
ID: row.ID, |
||||
StreamID: row.StreamID, |
||||
OutputConfigurationID: row.OutputConfigurationID, |
||||
Timestamp: row.Timestamp, |
||||
Path: row.Path, |
||||
} |
||||
segments = append(segments, segment) |
||||
} |
||||
|
||||
return segments, nil |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) createMediaPlaylistForConfigurationAndSegments(configuration *HLSOutputConfiguration, startTime time.Time, inProgress bool, segments []HLSSegment) (*m3u8.MediaPlaylist, error) { |
||||
playlistSize := len(segments) |
||||
segmentDuration := configuration.SegmentDuration |
||||
playlist, err := m3u8.NewMediaPlaylist(0, uint(playlistSize)) |
||||
|
||||
playlist.TargetDuration = configuration.SegmentDuration |
||||
|
||||
if !inProgress { |
||||
playlist.MediaType = m3u8.VOD |
||||
} else { |
||||
playlist.MediaType = m3u8.EVENT |
||||
} |
||||
|
||||
// Add the segments to the playlist.
|
||||
for index, segment := range segments { |
||||
mediaSegment := m3u8.MediaSegment{ |
||||
URI: "/" + segment.Path, |
||||
Duration: segmentDuration, |
||||
SeqId: uint64(index), |
||||
ProgramDateTime: segment.Timestamp, |
||||
} |
||||
if err := playlist.AppendSegment(&mediaSegment); err != nil { |
||||
return nil, errors.Wrap(err, "failed to append segment to recording playlist") |
||||
} |
||||
} |
||||
|
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Configure the properties of this media playlist.
|
||||
if err := playlist.SetProgramDateTime(startTime); err != nil { |
||||
return nil, errors.Wrap(err, "failed to set media playlist program date time") |
||||
} |
||||
|
||||
// Our live output is specified as v6, so let's match it to be as close as
|
||||
// possible to what we're doing for live streams.
|
||||
playlist.SetVersion(6) |
||||
|
||||
if !inProgress { |
||||
// Specify explicitly that the playlist content is allowed to be cached.
|
||||
// However, if in-progress recordings are supported this should not be enabled
|
||||
// in order for the playlist to be updated with new segments. inProgress is
|
||||
// determined by seeing if the stream has an endTime or not.
|
||||
playlist.SetCustomTag(&MediaPlaylistAllowCacheTag{}) |
||||
|
||||
// Set the ENDLIST tag and close the playlist for writing if the stream is
|
||||
// not still in progress.
|
||||
playlist.Close() |
||||
} |
||||
|
||||
return playlist, nil |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) createNewMasterPlaylist() *m3u8.MasterPlaylist { |
||||
playlist := m3u8.NewMasterPlaylist() |
||||
playlist.SetIndependentSegments(true) |
||||
playlist.SetVersion(6) |
||||
|
||||
return playlist |
||||
} |
||||
|
||||
func createConfigFromConfigRow(row db.GetOutputConfigurationForIdRow) *HLSOutputConfiguration { |
||||
config := HLSOutputConfiguration{ |
||||
ID: row.ID, |
||||
StreamId: row.StreamID, |
||||
VariantId: row.VariantID, |
||||
Name: row.Name, |
||||
VideoBitrate: int(row.Bitrate), |
||||
Framerate: int(row.Framerate), |
||||
ScaledHeight: int(row.ResolutionWidth.Int32), |
||||
ScaledWidth: int(row.ResolutionHeight.Int32), |
||||
SegmentDuration: float64(row.SegmentDuration), |
||||
} |
||||
return &config |
||||
} |
||||
|
||||
// func createOutputConfigsFromConfigRows(rows []db.GetOutputConfigurationsForStreamIdRow) []HLSOutputConfiguration {
|
||||
// outputConfigs := []HLSOutputConfiguration{}
|
||||
// for _, row := range rows {
|
||||
// config := HLSOutputConfiguration{
|
||||
// ID: row.ID,
|
||||
// StreamId: row.StreamID,
|
||||
// VariantId: row.VariantID,
|
||||
// Name: row.Name,
|
||||
// VideoBitrate: int(row.Bitrate),
|
||||
// Framerate: int(row.Framerate),
|
||||
// ScaledHeight: int(row.ResolutionWidth.Int32),
|
||||
// ScaledWidth: int(row.ResolutionHeight.Int32),
|
||||
// SegmentDuration: float64(row.SegmentDuration),
|
||||
// }
|
||||
// outputConfigs = append(outputConfigs, config)
|
||||
// }
|
||||
|
||||
// return outputConfigs
|
||||
// }
|
||||
@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/grafov/m3u8" |
||||
) |
||||
|
||||
var ( |
||||
generator = NewPlaylistGenerator() |
||||
config = []HLSOutputConfiguration{ |
||||
{ |
||||
ID: "1", |
||||
VideoBitrate: 1000, |
||||
Framerate: 30, |
||||
}, |
||||
{ |
||||
ID: "2", |
||||
VideoBitrate: 2000, |
||||
Framerate: 30, |
||||
}, |
||||
} |
||||
) |
||||
|
||||
var segments = []HLSSegment{ |
||||
{ |
||||
ID: "testSegmentId", |
||||
StreamID: "testStreamId", |
||||
Timestamp: time.Now(), |
||||
OutputConfigurationID: "testOutputConfigId", |
||||
Path: "hls/testStreamId/testOutputConfigId/testSegmentId.ts", |
||||
}, |
||||
} |
||||
|
||||
func TestMasterPlaylist(t *testing.T) { |
||||
playlist := generator.createNewMasterPlaylist() |
||||
|
||||
mediaPlaylists, err := generator.createMediaPlaylistForConfigurationAndSegments(&config[0], time.Now(), false, segments) |
||||
playlist.Append("test", mediaPlaylists, m3u8.VariantParams{ |
||||
Bandwidth: uint32(config[0].VideoBitrate), |
||||
FrameRate: float64(config[0].Framerate), |
||||
}) |
||||
mediaPlaylists.Close() |
||||
|
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
if playlist.Version() != 6 { |
||||
t.Error("expected version 6, got", playlist.Version()) |
||||
} |
||||
|
||||
if !playlist.IndependentSegments() { |
||||
t.Error("expected independent segments") |
||||
} |
||||
|
||||
if playlist.Variants[0].Bandwidth != uint32(config[0].VideoBitrate) { |
||||
t.Error("expected bandwidth", config[0].VideoBitrate, "got", playlist.Variants[0].Bandwidth) |
||||
} |
||||
|
||||
if playlist.Variants[0].FrameRate != float64(config[0].Framerate) { |
||||
t.Error("expected framerate", config[0].Framerate, "got", playlist.Variants[0].FrameRate) |
||||
} |
||||
} |
||||
|
||||
func TestCompletedMediaPlaylist(t *testing.T) { |
||||
startTime := segments[0].Timestamp |
||||
conf := config[0] |
||||
|
||||
// Create a completed media playlist.
|
||||
playlist, err := generator.createMediaPlaylistForConfigurationAndSegments(&conf, startTime, false, segments) |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
if playlist.TargetDuration != conf.SegmentDuration { |
||||
t.Error("expected target duration", conf.SegmentDuration, "got", playlist.TargetDuration) |
||||
} |
||||
|
||||
// Verify it's marked as cachable.
|
||||
if playlist.Custom["#EXT-X-ALLOW-CACHE"].String() != "#EXT-X-ALLOW-CACHE" { |
||||
t.Error("expected cachable playlist, tag not set") |
||||
} |
||||
|
||||
// Verify it has the correct number of segments in the media playlist.
|
||||
if int(playlist.Count()) != len(segments) { |
||||
t.Error("expected", len(segments), "segments, got", playlist.Count()) |
||||
} |
||||
|
||||
// Test the playlist version.
|
||||
if playlist.Version() != 6 { |
||||
t.Error("expected version 6, got", playlist.Version()) |
||||
} |
||||
|
||||
// Verify the playlist type
|
||||
if playlist.MediaType != m3u8.VOD { |
||||
t.Error("expected VOD playlist type, got type", playlist.MediaType) |
||||
} |
||||
|
||||
// Verify the first segment URI.
|
||||
if playlist.Segments[0].URI != "/"+segments[0].Path { |
||||
t.Error("expected segment URI", segments[0].Path, "got", playlist.Segments[0].URI) |
||||
} |
||||
} |
||||
|
||||
func TestInProgressMediaPlaylist(t *testing.T) { |
||||
startTime := segments[0].Timestamp |
||||
conf := config[0] |
||||
|
||||
// Create a completed media playlist.
|
||||
playlist, err := generator.createMediaPlaylistForConfigurationAndSegments(&conf, startTime, true, segments) |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
// Verify it's marked as cachable.
|
||||
if playlist.Custom != nil && playlist.Custom["#EXT-X-ALLOW-CACHE"].String() == "#EXT-X-ALLOW-CACHE" { |
||||
t.Error("expected non-achable playlist when stream is still in progress") |
||||
} |
||||
|
||||
// Verify it has the correct number of segments in the media playlist.
|
||||
if int(playlist.Count()) != len(segments) { |
||||
t.Error("expected", len(segments), "segments, got", playlist.Count()) |
||||
} |
||||
|
||||
// Test the playlist version.
|
||||
if playlist.Version() != 6 { |
||||
t.Error("expected version 6, got", playlist.Version()) |
||||
} |
||||
|
||||
// Verify the playlist type
|
||||
if playlist.MediaType != m3u8.EVENT { |
||||
t.Error("expected EVENT playlist type, got type", playlist.MediaType) |
||||
} |
||||
} |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
package replays |
||||
|
||||
type StorageProvider interface { |
||||
Setup() error |
||||
Save(localFilePath, destinationPath string, retryCount int) (string, error) |
||||
} |
||||
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/owncast/owncast/core/data" |
||||
"github.com/pkg/errors" |
||||
) |
||||
|
||||
type Stream struct { |
||||
ID string `json:"id"` |
||||
Title string `json:"title,omitempty"` |
||||
StartTime time.Time `json:"startTime"` |
||||
EndTime time.Time `json:"endTime,omitempty"` |
||||
InProgress bool `json:"inProgress,omitempty"` |
||||
Manifest string `json:"manifest,omitempty"` |
||||
} |
||||
|
||||
// GetStreams will return all streams that have been recorded.
|
||||
func GetStreams() ([]*Stream, error) { |
||||
streams, err := data.GetDatastore().GetQueries().GetStreams(context.Background()) |
||||
if err != nil { |
||||
return nil, errors.WithMessage(err, "failure to get streams") |
||||
} |
||||
|
||||
response := []*Stream{} |
||||
for _, stream := range streams { |
||||
s := Stream{ |
||||
ID: stream.ID, |
||||
Title: stream.StreamTitle.String, |
||||
StartTime: stream.StartTime, |
||||
EndTime: stream.EndTime.Time, |
||||
InProgress: !stream.EndTime.Valid, |
||||
Manifest: fmt.Sprintf("/replay/%s", stream.ID), |
||||
} |
||||
response = append(response, &s) |
||||
} |
||||
return response, nil |
||||
} |
||||
Loading…
Reference in new issue