18 changed files with 1054 additions and 206 deletions
@ -0,0 +1,151 @@ |
|||||||
|
package controllers |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/json" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"github.com/owncast/owncast/replays" |
||||||
|
log "github.com/sirupsen/logrus" |
||||||
|
) |
||||||
|
|
||||||
|
// GetAllClips will return all clips that have been previously created.
|
||||||
|
func GetAllClips(w http.ResponseWriter, r *http.Request) { |
||||||
|
clips, err := replays.GetAllClips() |
||||||
|
if err != nil { |
||||||
|
log.Errorln(err) |
||||||
|
w.WriteHeader(http.StatusInternalServerError) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
WriteResponse(w, clips) |
||||||
|
} |
||||||
|
|
||||||
|
// AddClip will create a new clip for a given stream and time window.
|
||||||
|
func AddClip(w http.ResponseWriter, r *http.Request) { |
||||||
|
type addClipRequest struct { |
||||||
|
StreamId string `json:"streamId"` |
||||||
|
ClipTitle string `json:"clipTitle"` |
||||||
|
RelativeStartTimeSeconds float32 `json:"relativeStartTimeSeconds"` |
||||||
|
RelativeEndTimeSeconds float32 `json:"relativeEndTimeSeconds"` |
||||||
|
} |
||||||
|
|
||||||
|
if r.Method != http.MethodPost { |
||||||
|
BadRequestHandler(w, nil) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
decoder := json.NewDecoder(r.Body) |
||||||
|
var request addClipRequest |
||||||
|
|
||||||
|
if request.RelativeEndTimeSeconds < request.RelativeStartTimeSeconds { |
||||||
|
BadRequestHandler(w, errors.New("end time must be after start time")) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if err := decoder.Decode(&request); err != nil { |
||||||
|
log.Errorln(err) |
||||||
|
WriteSimpleResponse(w, false, "unable to create clip") |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
streamId := request.StreamId |
||||||
|
clipTitle := request.ClipTitle |
||||||
|
startTime := request.RelativeStartTimeSeconds |
||||||
|
endTime := request.RelativeEndTimeSeconds |
||||||
|
|
||||||
|
// Some validation
|
||||||
|
playlistGenerator := replays.NewPlaylistGenerator() |
||||||
|
|
||||||
|
stream, err := playlistGenerator.GetStream(streamId) |
||||||
|
if err != nil { |
||||||
|
BadRequestHandler(w, errors.New("stream not found")) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if stream.StartTime.IsZero() { |
||||||
|
BadRequestHandler(w, errors.New("stream start time not found")) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Make sure the proposed clip start time and end time are within
|
||||||
|
// the start and end time of the stream.
|
||||||
|
finalSegment, err := replays.GetFinalSegmentForStream(streamId) |
||||||
|
if err != nil { |
||||||
|
InternalErrorHandler(w, err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if finalSegment.RelativeTimestamp < startTime { |
||||||
|
BadRequestHandler(w, errors.New("start time is after the known end of the stream")) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
clipId, duration, err := replays.AddClipForStream(streamId, clipTitle, "", startTime, endTime) |
||||||
|
if err != nil { |
||||||
|
log.Errorln(err) |
||||||
|
w.WriteHeader(http.StatusInternalServerError) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
WriteSimpleResponse(w, true, "clip "+clipId+" created with duration of "+fmt.Sprint(duration)+" seconds") |
||||||
|
} |
||||||
|
|
||||||
|
// GetClip will return playable content for a given clip Id.
|
||||||
|
func GetClip(w http.ResponseWriter, r *http.Request) { |
||||||
|
pathComponents := strings.Split(r.URL.Path, "/") |
||||||
|
if len(pathComponents) == 3 { |
||||||
|
// Return the master playlist for the requested stream
|
||||||
|
clipId := pathComponents[2] |
||||||
|
getClipMasterPlaylist(clipId, w) |
||||||
|
return |
||||||
|
} else if len(pathComponents) == 4 { |
||||||
|
// Return the media playlist for the requested stream and output config
|
||||||
|
clipId := pathComponents[2] |
||||||
|
outputConfigId := pathComponents[3] |
||||||
|
getClipMediaPlaylist(clipId, outputConfigId, w) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
BadRequestHandler(w, nil) |
||||||
|
} |
||||||
|
|
||||||
|
// getReplayMasterPlaylist will return a complete replay of a stream
|
||||||
|
// as a HLS playlist.
|
||||||
|
func getClipMasterPlaylist(clipId string, w http.ResponseWriter) { |
||||||
|
playlistGenerator := replays.NewPlaylistGenerator() |
||||||
|
playlist, err := playlistGenerator.GenerateMasterPlaylistForClip(clipId) |
||||||
|
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 |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// getClipMediaPlaylist will return media playlist for a given clip
|
||||||
|
// and stream output configuration.
|
||||||
|
func getClipMediaPlaylist(clipId, outputConfigId string, w http.ResponseWriter) { |
||||||
|
playlistGenerator := replays.NewPlaylistGenerator() |
||||||
|
playlist, err := playlistGenerator.GenerateMediaPlaylistForClipAndConfiguration(clipId, 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,63 @@ |
|||||||
|
package models |
||||||
|
|
||||||
|
import ( |
||||||
|
"database/sql" |
||||||
|
"errors" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
type FlexibleDate struct { |
||||||
|
time.Time |
||||||
|
} |
||||||
|
|
||||||
|
func (self *FlexibleDate) UnmarshalJSON(b []byte) (err error) { |
||||||
|
s := string(b) |
||||||
|
|
||||||
|
// Get rid of the quotes "" around the value.
|
||||||
|
s = s[1 : len(s)-1] |
||||||
|
|
||||||
|
result, err := FlexibleDateParse(s) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
self.Time = result |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// FlexibleDateParse is a convinience function to parse a date that could be
|
||||||
|
// a string, a time.Time, or a sql.NullTime.
|
||||||
|
func FlexibleDateParse(date interface{}) (time.Time, error) { |
||||||
|
// If it's within a sql.NullTime wrapper, return the time from that.
|
||||||
|
nulltime, ok := date.(sql.NullTime) |
||||||
|
if ok { |
||||||
|
return nulltime.Time, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Parse as string
|
||||||
|
datestring, ok := date.(string) |
||||||
|
if ok { |
||||||
|
t, err := time.Parse(time.RFC3339Nano, datestring) |
||||||
|
if err == nil { |
||||||
|
return t, nil |
||||||
|
} |
||||||
|
|
||||||
|
t, err = time.Parse("2006-01-02T15:04:05.999999999Z0700", datestring) |
||||||
|
if err == nil { |
||||||
|
return t, nil |
||||||
|
} |
||||||
|
|
||||||
|
t, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", datestring) |
||||||
|
if err == nil { |
||||||
|
return t, nil |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
dateobject, ok := date.(time.Time) |
||||||
|
if ok { |
||||||
|
return dateobject, nil |
||||||
|
} |
||||||
|
|
||||||
|
return time.Time{}, errors.New("unable to parse date") |
||||||
|
} |
@ -0,0 +1,42 @@ |
|||||||
|
package models |
||||||
|
|
||||||
|
import ( |
||||||
|
"database/sql" |
||||||
|
"encoding/json" |
||||||
|
"testing" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
func TestFlexibleDateParsing(t *testing.T) { |
||||||
|
type testJson struct { |
||||||
|
Testdate FlexibleDate `json:"testdate"` |
||||||
|
} |
||||||
|
|
||||||
|
nullTime := sql.NullTime{Time: time.Unix(1591614434, 0), Valid: true} |
||||||
|
testNullTime, err := FlexibleDateParse(nullTime) |
||||||
|
if err != nil { |
||||||
|
t.Error(err) |
||||||
|
} |
||||||
|
|
||||||
|
if testNullTime.Unix() != nullTime.Time.Unix() { |
||||||
|
t.Errorf("Expected %d but got %d", nullTime.Time.Unix(), testNullTime.Unix()) |
||||||
|
} |
||||||
|
|
||||||
|
testStrings := map[string]time.Time{ |
||||||
|
"2023-08-10 17:40:15.376736475-07:00": time.Unix(1691714415, 0), |
||||||
|
} |
||||||
|
|
||||||
|
for testString, expectedTime := range testStrings { |
||||||
|
testJsonString := `{"testdate":"` + testString + `"}` |
||||||
|
response := testJson{} |
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(testJsonString), &response) |
||||||
|
if err != nil { |
||||||
|
t.Error(err) |
||||||
|
} |
||||||
|
|
||||||
|
if response.Testdate.Time.Unix() != expectedTime.Unix() { |
||||||
|
t.Errorf("Expected %d but got %d", expectedTime.Unix(), response.Testdate.Time.Unix()) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,141 @@ |
|||||||
|
package replays |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"database/sql" |
||||||
|
"fmt" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/owncast/owncast/core/data" |
||||||
|
"github.com/owncast/owncast/db" |
||||||
|
"github.com/owncast/owncast/utils" |
||||||
|
"github.com/pkg/errors" |
||||||
|
"github.com/teris-io/shortid" |
||||||
|
) |
||||||
|
|
||||||
|
// Clip represents a clip that has been created from a stream.
|
||||||
|
// A clip is a subset of a stream that has has start and end seconds
|
||||||
|
// relative to the start of the stream.
|
||||||
|
type Clip struct { |
||||||
|
ID string `json:"id"` |
||||||
|
StreamId string `json:"stream_id"` |
||||||
|
ClippedBy string `json:"clipped_by,omitempty"` |
||||||
|
ClipTitle string `json:"title,omitempty"` |
||||||
|
StreamTitle string `json:"stream_title,omitempty"` |
||||||
|
RelativeStartTime float32 `json:"relativeStartTime"` |
||||||
|
RelativeEndTime float32 `json:"relativeEndTime"` |
||||||
|
DurationSeconds int `json:"durationSeconds"` |
||||||
|
Manifest string `json:"manifest,omitempty"` |
||||||
|
Timestamp time.Time `json:"timestamp"` |
||||||
|
} |
||||||
|
|
||||||
|
// GetClips will return all clips that have been recorded.
|
||||||
|
func GetAllClips() ([]*Clip, error) { |
||||||
|
clips, err := data.GetDatastore().GetQueries().GetAllClips(context.Background()) |
||||||
|
if err != nil { |
||||||
|
return nil, errors.WithMessage(err, "failure to get clips") |
||||||
|
} |
||||||
|
|
||||||
|
response := []*Clip{} |
||||||
|
for _, clip := range clips { |
||||||
|
s := Clip{ |
||||||
|
ID: clip.ID, |
||||||
|
ClipTitle: clip.ClipTitle.String, |
||||||
|
StreamId: clip.StreamID, |
||||||
|
StreamTitle: clip.StreamTitle.String, |
||||||
|
RelativeStartTime: float32(clip.RelativeStartTime.Float64), |
||||||
|
RelativeEndTime: float32(clip.RelativeEndTime.Float64), |
||||||
|
DurationSeconds: int(clip.DurationSeconds), |
||||||
|
Timestamp: clip.Timestamp.Time, |
||||||
|
Manifest: fmt.Sprintf("/clip/%s", clip.ID), |
||||||
|
} |
||||||
|
response = append(response, &s) |
||||||
|
} |
||||||
|
return response, nil |
||||||
|
} |
||||||
|
|
||||||
|
// GetAllClipsForStream will return all clips that have been recorded for a stream.
|
||||||
|
func GetAllClipsForStream(streamId string) ([]*Clip, error) { |
||||||
|
clips, err := data.GetDatastore().GetQueries().GetAllClipsForStream(context.Background(), streamId) |
||||||
|
if err != nil { |
||||||
|
return nil, errors.WithMessage(err, "failure to get clips") |
||||||
|
} |
||||||
|
|
||||||
|
response := []*Clip{} |
||||||
|
for _, clip := range clips { |
||||||
|
s := Clip{ |
||||||
|
ID: clip.ClipID, |
||||||
|
ClipTitle: clip.ClipTitle.String, |
||||||
|
StreamTitle: clip.StreamTitle.String, |
||||||
|
RelativeStartTime: float32(clip.RelativeStartTime.Float64), |
||||||
|
RelativeEndTime: float32(clip.RelativeEndTime.Float64), |
||||||
|
Timestamp: clip.Timestamp.Time, |
||||||
|
Manifest: fmt.Sprintf("/clips/%s", clip.ClipID), |
||||||
|
} |
||||||
|
response = append(response, &s) |
||||||
|
} |
||||||
|
return response, nil |
||||||
|
} |
||||||
|
|
||||||
|
// AddClipForStream will save a new clip for a stream.
|
||||||
|
func AddClipForStream(streamId, clipTitle, clippedBy string, relativeStartTimeSeconds, relativeEndTimeSeconds float32) (string, int, error) { |
||||||
|
playlistGenerator := NewPlaylistGenerator() |
||||||
|
|
||||||
|
// Verify this stream exists
|
||||||
|
if _, err := playlistGenerator.GetStream(streamId); err != nil { |
||||||
|
return "", 0, errors.WithMessage(err, "stream not found") |
||||||
|
} |
||||||
|
|
||||||
|
// Verify this stream has at least one output configuration.
|
||||||
|
configs, err := playlistGenerator.GetConfigurationsForStream(streamId) |
||||||
|
if err != nil { |
||||||
|
return "", 0, errors.WithMessage(err, "unable to get configurations for stream") |
||||||
|
} |
||||||
|
|
||||||
|
if len(configs) == 0 { |
||||||
|
return "", 0, errors.New("no configurations found for stream") |
||||||
|
} |
||||||
|
|
||||||
|
// We want the start and end seconds to be aligned to the segment so
|
||||||
|
// round up and down the values to get a fully inclusive segment range.
|
||||||
|
config := configs[0] |
||||||
|
segmentDuration := int(config.SegmentDuration) |
||||||
|
|
||||||
|
updatedRelativeStartTimeSeconds := utils.RoundDownToNearest(relativeStartTimeSeconds, segmentDuration) |
||||||
|
updatedRelativeEndTimeSeconds := utils.RoundUpToNearest(relativeEndTimeSeconds, segmentDuration) |
||||||
|
clipId := shortid.MustGenerate() |
||||||
|
duration := updatedRelativeEndTimeSeconds - updatedRelativeStartTimeSeconds |
||||||
|
|
||||||
|
err = data.GetDatastore().GetQueries().InsertClip(context.Background(), db.InsertClipParams{ |
||||||
|
ID: clipId, |
||||||
|
StreamID: streamId, |
||||||
|
ClipTitle: sql.NullString{String: clipTitle, Valid: clipTitle != ""}, |
||||||
|
RelativeStartTime: sql.NullFloat64{Float64: float64(updatedRelativeStartTimeSeconds), Valid: true}, |
||||||
|
RelativeEndTime: sql.NullFloat64{Float64: float64(updatedRelativeEndTimeSeconds), Valid: true}, |
||||||
|
Timestamp: sql.NullTime{Time: time.Now(), Valid: true}, |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
return "", 0, errors.WithMessage(err, "failure to add clip") |
||||||
|
} |
||||||
|
|
||||||
|
return clipId, duration, nil |
||||||
|
} |
||||||
|
|
||||||
|
// GetFinalSegmentForStream will return the final known segment for a stream.
|
||||||
|
func GetFinalSegmentForStream(streamId string) (*HLSSegment, error) { |
||||||
|
segmentResponse, err := data.GetDatastore().GetQueries().GetFinalSegmentForStream(context.Background(), streamId) |
||||||
|
if err != nil { |
||||||
|
return nil, errors.Wrap(err, "unable to get final segment for stream") |
||||||
|
} |
||||||
|
|
||||||
|
segment := HLSSegment{ |
||||||
|
ID: segmentResponse.ID, |
||||||
|
StreamID: segmentResponse.StreamID, |
||||||
|
OutputConfigurationID: segmentResponse.OutputConfigurationID, |
||||||
|
Path: segmentResponse.Path, |
||||||
|
RelativeTimestamp: segmentResponse.RelativeTimestamp, |
||||||
|
Timestamp: segmentResponse.Timestamp.Time, |
||||||
|
} |
||||||
|
|
||||||
|
return &segment, nil |
||||||
|
} |
@ -0,0 +1,142 @@ |
|||||||
|
package replays |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"github.com/grafov/m3u8" |
||||||
|
"github.com/owncast/owncast/db" |
||||||
|
"github.com/owncast/owncast/models" |
||||||
|
"github.com/pkg/errors" |
||||||
|
) |
||||||
|
|
||||||
|
// GenerateMasterPlaylistForClip returns a master playlist for a given clip Id.
|
||||||
|
// It includes references to the media playlists for each output configuration.
|
||||||
|
func (p *PlaylistGenerator) GenerateMasterPlaylistForClip(clipId string) (*m3u8.MasterPlaylist, error) { |
||||||
|
clip, err := p.datastore.GetQueries().GetClip(context.Background(), clipId) |
||||||
|
if err != nil { |
||||||
|
return nil, errors.Wrap(err, "unable to fetch requested clip") |
||||||
|
} |
||||||
|
|
||||||
|
streamId := clip.StreamID |
||||||
|
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 err := config.Validate(); err != nil { |
||||||
|
return nil, errors.Wrap(err, "invalid output configuration") |
||||||
|
} |
||||||
|
|
||||||
|
mediaPlaylist, err := p.GenerateMediaPlaylistForClipAndConfiguration(clipId, config.ID) |
||||||
|
if err != nil { |
||||||
|
return nil, errors.Wrap(err, "failed to create clip media playlist") |
||||||
|
} |
||||||
|
|
||||||
|
// Append the media playlist to the master playlist.
|
||||||
|
params := p.getMediaPlaylistParamsForConfig(config) |
||||||
|
|
||||||
|
// Add the media playlist to the master playlist.
|
||||||
|
publicPlaylistPath := strings.Join([]string{"/clip", clipId, config.ID}, "/") |
||||||
|
masterPlaylist.Append(publicPlaylistPath, mediaPlaylist, params) |
||||||
|
} |
||||||
|
|
||||||
|
// Return the final master playlist that contains all the media playlists.
|
||||||
|
return masterPlaylist, nil |
||||||
|
} |
||||||
|
|
||||||
|
// GenerateMediaPlaylistForClipAndConfiguration returns a media playlist for a
|
||||||
|
// given clip Id and output configuration.
|
||||||
|
func (p *PlaylistGenerator) GenerateMediaPlaylistForClipAndConfiguration(clipId, outputConfigurationId string) (*m3u8.MediaPlaylist, error) { |
||||||
|
clip, err := p.GetClip(clipId) |
||||||
|
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") |
||||||
|
} |
||||||
|
|
||||||
|
clipStartSeconds := clip.RelativeStartTime |
||||||
|
clipEndSeconds := clip.RelativeEndTime |
||||||
|
|
||||||
|
// Fetch all the segments for this configuration.
|
||||||
|
segments, err := p.GetAllSegmentsForOutputConfigurationAndWindow(outputConfigurationId, clipStartSeconds, clipEndSeconds) |
||||||
|
if err != nil { |
||||||
|
return nil, errors.Wrap(err, "failed to get all clip segments for output configuration") |
||||||
|
} |
||||||
|
|
||||||
|
// Create the media playlist for this configuration and add the segments.
|
||||||
|
mediaPlaylist, err := p.createMediaPlaylistForConfigurationAndSegments(config, clip.Timestamp, false, segments) |
||||||
|
if err != nil { |
||||||
|
return nil, errors.Wrap(err, "failed to create clip media playlist") |
||||||
|
} |
||||||
|
|
||||||
|
return mediaPlaylist, nil |
||||||
|
} |
||||||
|
|
||||||
|
// GetClip returns a clip by its ID.
|
||||||
|
func (p *PlaylistGenerator) GetClip(clipId string) (*Clip, error) { |
||||||
|
clip, err := p.datastore.GetQueries().GetClip(context.Background(), clipId) |
||||||
|
if err != nil { |
||||||
|
return nil, errors.Wrap(err, "failed to get clip") |
||||||
|
} |
||||||
|
|
||||||
|
if clip.ClipID == "" { |
||||||
|
return nil, errors.Wrap(err, "failed to get clip") |
||||||
|
} |
||||||
|
|
||||||
|
if !clip.RelativeEndTime.Valid { |
||||||
|
return nil, errors.Wrap(err, "failed to get clip") |
||||||
|
} |
||||||
|
|
||||||
|
timestamp, err := models.FlexibleDateParse(clip.ClipTimestamp) |
||||||
|
if err != nil { |
||||||
|
return nil, errors.Wrap(err, "failed to parse clip timestamp") |
||||||
|
} |
||||||
|
|
||||||
|
c := Clip{ |
||||||
|
ID: clip.ClipID, |
||||||
|
StreamId: clip.StreamID, |
||||||
|
ClipTitle: clip.ClipTitle.String, |
||||||
|
RelativeStartTime: float32(clip.RelativeStartTime.Float64), |
||||||
|
RelativeEndTime: float32(clip.RelativeEndTime.Float64), |
||||||
|
Timestamp: timestamp, |
||||||
|
} |
||||||
|
|
||||||
|
return &c, nil |
||||||
|
} |
||||||
|
|
||||||
|
// GetAllSegmentsForOutputConfigurationAndWindow returns all the segments for a
|
||||||
|
// given output config and time window.
|
||||||
|
func (p *PlaylistGenerator) GetAllSegmentsForOutputConfigurationAndWindow(configId string, startSeconds, endSeconds float32) ([]HLSSegment, error) { |
||||||
|
segmentRows, err := p.datastore.GetQueries().GetSegmentsForOutputIdAndWindow(context.Background(), db.GetSegmentsForOutputIdAndWindowParams{ |
||||||
|
OutputConfigurationID: configId, |
||||||
|
StartSeconds: startSeconds, |
||||||
|
EndSeconds: endSeconds, |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
return nil, errors.Wrap(err, "failed to get clip segments for output config") |
||||||
|
} |
||||||
|
|
||||||
|
segments := []HLSSegment{} |
||||||
|
for _, row := range segmentRows { |
||||||
|
segment := HLSSegment{ |
||||||
|
ID: row.ID, |
||||||
|
StreamID: row.StreamID, |
||||||
|
OutputConfigurationID: row.OutputConfigurationID, |
||||||
|
Timestamp: row.Timestamp.Time, |
||||||
|
Path: row.Path, |
||||||
|
} |
||||||
|
segments = append(segments, segment) |
||||||
|
} |
||||||
|
|
||||||
|
return segments, nil |
||||||
|
} |
@ -0,0 +1,129 @@ |
|||||||
|
package replays |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"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) { |
||||||
|
// 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 err := config.Validate(); err != nil { |
||||||
|
return nil, errors.Wrap(err, "invalid output configuration") |
||||||
|
} |
||||||
|
|
||||||
|
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 := p.getMediaPlaylistParamsForConfig(config) |
||||||
|
|
||||||
|
// 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.Time, |
||||||
|
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 |
||||||
|
} |
||||||
|
|
||||||
|
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 |
||||||
|
} |
Loading…
Reference in new issue