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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
package replays |
||||||
|
|
||||||
|
type StorageProvider interface { |
||||||
|
Setup() error |
||||||
|
Save(localFilePath, destinationPath string, retryCount int) (string, error) |
||||||
|
} |
||||||
@ -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