diff --git a/go.mod b/go.mod index f091169..f232466 100644 --- a/go.mod +++ b/go.mod @@ -1 +1,3 @@ module m3u8-downloader + +go 1.23.0 diff --git a/main.go b/main.go index 27d4896..e7f4623 100644 --- a/main.go +++ b/main.go @@ -7,9 +7,21 @@ import ( func main() { - master_url := "https://d17cyqyz9yhmep.cloudfront.net/streams/234945/playlist_1752291107574_1752292056713.m3u8" + masterUrl := "https://d17cyqyz9yhmep.cloudfront.net/streams/234945/playlist_1752291107574_1752292056713.m3u8" - streams := media.GetStreamMetadata(master_url) + stream, err := media.ParseMasterPlaylist(masterUrl) + if err != nil { + panic(err) + } - fmt.Println(streams) + audio, video, err := stream.FetchSegmentPlaylists() + if err != nil { + panic(err) + } + + videoPlaylist := media.ParseMediaPlaylist(video) + audioPlaylist := media.ParseMediaPlaylist(audio) + + fmt.Println(videoPlaylist) + fmt.Println(audioPlaylist) } diff --git a/media/client.go b/media/client.go new file mode 100644 index 0000000..25ba493 --- /dev/null +++ b/media/client.go @@ -0,0 +1,103 @@ +package media + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +const ( + DefaultTimeout = 30 * time.Second + UserAgent = "M3U8-Downloader/1.0" +) + +type HTTPClient struct { + client *http.Client + headers map[string]string +} + +func NewHTTPClient() *HTTPClient { + return &HTTPClient{ + client: &http.Client{ + Timeout: DefaultTimeout, + }, + headers: map[string]string{ + "User-Agent": UserAgent, + }, + } +} + +func (c *HTTPClient) SetTimeout(timeout time.Duration) { + c.client.Timeout = timeout +} + +func (c *HTTPClient) SetHeader(key, value string) { + c.headers[key] = value +} + +func (c *HTTPClient) Get(url string) ([]byte, error) { + return c.GetWithContext(context.Background(), url) +} + +func (c *HTTPClient) GetWithContext(ctx context.Context, url string) ([]byte, error) { + if !ValidateURL(url) { + return nil, errors.New("invalid URL format") + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + for key, value := range c.headers { + req.Header.Set(key, value) + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + return data, nil +} + +// Global client instance for convenience functions +var defaultClient = NewHTTPClient() + +func FetchPlaylistContent(url string) (string, error) { + data, err := defaultClient.Get(url) + if err != nil { + return "", err + } + return string(data), nil +} + +func FetchPlaylistContentWithContext(ctx context.Context, url string) (string, error) { + data, err := defaultClient.GetWithContext(ctx, url) + if err != nil { + return "", err + } + return string(data), nil +} + +func SetDefaultTimeout(timeout time.Duration) { + defaultClient.SetTimeout(timeout) +} + +func SetDefaultUserAgent(userAgent string) { + defaultClient.SetHeader("User-Agent", userAgent) +} diff --git a/media/constants.go b/media/constants.go new file mode 100644 index 0000000..d068929 --- /dev/null +++ b/media/constants.go @@ -0,0 +1,17 @@ +package media + +import "time" + +const ( + ExtXStreamInf = "#EXT-X-STREAM-INF" + ExtXMedia = "#EXT-X-MEDIA" + ExtXTargetDuration = "#EXT-X-TARGETDURATION" + ExtInf = "#EXTINF" + ExtXEndList = "#EXT-X-ENDLIST" + ExtXPlaylistType = "#EXT-X-PLAYLIST-TYPE" + HTTPPrefix = "http://" + HTTPSPrefix = "https://" + ClientDefaultTimeout = 30 * time.Second + HTTPUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0" + PlaylistTypeVOD = "VOD" +) diff --git a/media/parser.go b/media/parser.go new file mode 100644 index 0000000..2865545 --- /dev/null +++ b/media/parser.go @@ -0,0 +1,88 @@ +package media + +import ( + "strconv" + "strings" +) + +func ParseAttribute(attribute string) string { + parts := strings.Split(attribute, "=") + if len(parts) >= 2 { + return parts[1] + } + return "" +} + +func StripQuotes(input string) string { + return strings.Trim(input, "\"") +} + +func ResolutionToPixels(resolution string) int { + parts := strings.Split(resolution, "x") + if len(parts) != 2 { + return 0 + } + + width, err := strconv.Atoi(parts[0]) + if err != nil { + return 0 + } + + height, err := strconv.Atoi(parts[1]) + if err != nil { + return 0 + } + + return width * height +} + +func parseMediaAttributes(mediaInfo string) map[string]string { + attributes := make(map[string]string) + + // Remove the #EXT-X-MEDIA: prefix + content := strings.TrimPrefix(mediaInfo, ExtXMedia+":") + + // Split by comma, but respect quoted values + parts := splitRespectingQuotes(content, ',') + + for _, part := range parts { + if kv := strings.SplitN(strings.TrimSpace(part), "=", 2); len(kv) == 2 { + attributes[kv[0]] = kv[1] + } + } + + return attributes +} + +func splitRespectingQuotes(input string, delimiter rune) []string { + var result []string + var current strings.Builder + inQuotes := false + + for _, char := range input { + switch char { + case '"': + inQuotes = !inQuotes + current.WriteRune(char) + case delimiter: + if inQuotes { + current.WriteRune(char) + } else { + result = append(result, current.String()) + current.Reset() + } + default: + current.WriteRune(char) + } + } + + if current.Len() > 0 { + result = append(result, current.String()) + } + + return result +} + +func ValidateURL(url string) bool { + return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") +} diff --git a/media/playlist.go b/media/playlist.go new file mode 100644 index 0000000..a85751d --- /dev/null +++ b/media/playlist.go @@ -0,0 +1,71 @@ +package media + +import ( + "fmt" + "strings" +) + +type PlaylistMetadata struct { + URL string + Domain string + StreamID string +} + +func NewPlaylistMetadata(masterURL string) *PlaylistMetadata { + strippedURL := strings.Replace(masterURL, HTTPSPrefix, "", 1) + urlPrefix := strings.Split(strippedURL, "/")[0] + + return &PlaylistMetadata{ + URL: masterURL, + Domain: urlPrefix, + StreamID: strings.Split(strippedURL, "/")[2], + } +} + +type StreamSet struct { + Metadata *PlaylistMetadata + VideoStreams []VideoStream + AudioStreams []AudioStream +} + +func (s *StreamSet) BuildStreamURL(endpoint string) string { + return fmt.Sprintf("%s%s/streams/%s/%s", + HTTPSPrefix, s.Metadata.Domain, s.Metadata.StreamID, endpoint) +} + +func ParseMasterPlaylist(masterURL string) (*StreamSet, error) { + metadata := NewPlaylistMetadata(masterURL) + + content, err := FetchPlaylistContent(masterURL) + if err != nil { + return nil, err + } + + videoStreams, audioStreams := parsePlaylistLines(content) + + return &StreamSet{ + Metadata: metadata, + VideoStreams: videoStreams, + AudioStreams: audioStreams, + }, nil +} + +func parsePlaylistLines(content string) ([]VideoStream, []AudioStream) { + lines := strings.Split(content, "\n") + var videoStreams []VideoStream + var audioStreams []AudioStream + + for i, line := range lines { + if strings.HasPrefix(line, ExtXStreamInf) && i+1 < len(lines) { + if video, err := NewVideoStream(line, lines[i+1]); err == nil { + videoStreams = append(videoStreams, *video) + } + } else if strings.HasPrefix(line, ExtXMedia) { + if audio, err := NewAudioStream(line); err == nil { + audioStreams = append(audioStreams, *audio) + } + } + } + + return videoStreams, audioStreams +} diff --git a/media/segment.go b/media/segment.go new file mode 100644 index 0000000..636a17d --- /dev/null +++ b/media/segment.go @@ -0,0 +1,107 @@ +package media + +import ( + "errors" + "strconv" + "strings" +) + +type Segment struct { + URL string + Duration float64 + Sequence int +} + +type MediaPlaylist struct { + Segments []Segment + TargetDuration float64 + IsLive bool + HasEndList bool +} + +func (s *StreamSet) FetchSegmentPlaylists() (videoPlaylist, audioPlaylist string, err error) { + video, audio := s.SelectBestQualityStreams() + if video == nil { + return "", "", errors.New("no video streams available") + } + + videoURL := s.BuildStreamURL(video.URL) + + videoContent, err := FetchPlaylistContent(StripQuotes(videoURL)) + if err != nil { + return "", "", err + } + + var audioContent string + if audio != nil { + audioURL := s.BuildStreamURL(StripQuotes(audio.URL)) + audioContent, err = FetchPlaylistContent(StripQuotes(audioURL)) + if err != nil { + return "", "", err + } + } + + return videoContent, audioContent, nil +} + +func ParseMediaPlaylist(content string) *MediaPlaylist { + lines := strings.Split(content, "\n") + var segments []Segment + + playlist := &MediaPlaylist{ + IsLive: true, + HasEndList: false, + } + + mediaSequence := 0 + + for i, line := range lines { + switch { + case strings.HasPrefix(line, ExtXTargetDuration): + playlist.TargetDuration = parseTargetDuration(line) + + case strings.HasPrefix(line, ExtInf): + if i+1 < len(lines) { + duration := parseSegmentDuration(line) + segments = append(segments, Segment{ + URL: lines[i+1], + Duration: duration, + Sequence: mediaSequence, + }) + mediaSequence++ + } + + case strings.HasPrefix(line, ExtXPlaylistType): + if strings.Contains(line, PlaylistTypeVOD) { + playlist.IsLive = false + } + + case strings.HasPrefix(line, ExtXEndList): + playlist.HasEndList = true + } + } + + playlist.Segments = segments + return playlist +} + +func parseTargetDuration(line string) float64 { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + if duration, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64); err == nil { + return duration + } + } + return 0.0 +} + +func parseSegmentDuration(line string) float64 { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + durationPart := strings.Split(parts[1], ",")[0] + if duration, err := strconv.ParseFloat(durationPart, 64); err == nil { + return duration + } + } + return 0.0 +} diff --git a/media/stream.go b/media/stream.go new file mode 100644 index 0000000..1f724e9 --- /dev/null +++ b/media/stream.go @@ -0,0 +1,98 @@ +package media + +import ( + "errors" + "regexp" + "strconv" + "strings" +) + +type VideoStream struct { + URL string + Bandwidth int + Codecs string + Resolution string + FrameRate string + AudioGroup string +} + +func NewVideoStream(streamInfo, url string) (*VideoStream, error) { + if !strings.HasPrefix(streamInfo, ExtXStreamInf) { + return nil, errors.New("invalid stream info line") + } + + reg := regexp.MustCompile(`([A-Z0-9-]+)=(".*?"|[^,]*)`) + matches := reg.FindAllStringSubmatch(streamInfo, -1) + + if len(matches) < 5 { + return nil, errors.New("insufficient stream attributes") + } + + bandwidth, err := strconv.Atoi(matches[0][2]) + if err != nil { + return nil, err + } + + return &VideoStream{ + URL: url, + Bandwidth: bandwidth, + Codecs: StripQuotes(matches[1][2]), + Resolution: StripQuotes(matches[2][2]), + FrameRate: StripQuotes(matches[3][2]), + AudioGroup: StripQuotes(matches[4][2]), + }, nil +} + +type AudioStream struct { + URL string + MediaType string + GroupID string + Name string + IsDefault bool + AutoSelect bool +} + +func NewAudioStream(mediaInfo string) (*AudioStream, error) { + if !strings.HasPrefix(mediaInfo, ExtXMedia) { + return nil, errors.New("invalid media info line") + } + + attributes := parseMediaAttributes(mediaInfo) + + return &AudioStream{ + URL: StripQuotes(attributes["URI"]), + MediaType: StripQuotes(attributes["TYPE"]), + GroupID: StripQuotes(attributes["GROUP-ID"]), + Name: StripQuotes(attributes["NAME"]), + IsDefault: attributes["DEFAULT"] == "YES", + AutoSelect: attributes["AUTOSELECT"] == "YES", + }, nil +} + +func (s *StreamSet) SelectBestQualityStreams() (*VideoStream, *AudioStream) { + if len(s.VideoStreams) == 0 { + return nil, nil + } + + bestVideo := s.VideoStreams[0] + maxPixels := ResolutionToPixels(bestVideo.Resolution) + + for _, video := range s.VideoStreams { + pixels := ResolutionToPixels(video.Resolution) + if video.Bandwidth > bestVideo.Bandwidth || pixels > maxPixels { + bestVideo = video + maxPixels = pixels + } + } + + return &bestVideo, s.FindAudioStreamByGroup(bestVideo.AudioGroup) +} + +func (s *StreamSet) FindAudioStreamByGroup(groupID string) *AudioStream { + for _, audio := range s.AudioStreams { + if audio.GroupID == groupID { + return &audio + } + } + return nil +} diff --git a/media/types.go b/media/types.go deleted file mode 100644 index 3b512bd..0000000 --- a/media/types.go +++ /dev/null @@ -1,113 +0,0 @@ -package media - -import ( - "io" - "net/http" - "regexp" - "strconv" - "strings" -) - -type StreamSet struct { - PlaylistURL string - VideoURLs []VideoURL - AudioURLs []AudioURL -} - -func GetStreamMetadata(master_url string) *StreamSet { - resp, err := http.Get(master_url) - if err != nil { - panic(err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - panic(resp.StatusCode) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - panic(err) - } - - content := string(data) - lines := strings.Split(content, "\n") - video_urls := []VideoURL{} - audio_urls := []AudioURL{} - for i, line := range lines { - if strings.HasPrefix(line, "#EXT-X-STREAM-INF") { - video := NewVideoURL(lines[i], lines[i+1]) - video_urls = append(video_urls, *video) - } else if strings.HasPrefix(line, "#EXT-X-MEDIA") { - audio := NewAudioURL(lines[i]) - audio_urls = append(audio_urls, *audio) - } - } - - return &StreamSet{ - PlaylistURL: master_url, - VideoURLs: video_urls, - AudioURLs: audio_urls, - } -} - -type VideoURL struct { - URL string - Bandwidth int - Codecs string - Resolution string - FrameRate string - Audio string -} - -func NewVideoURL(input string, url string) *VideoURL { - if strings.HasPrefix(input, "#EXT-X-STREAM-INF") { - reg := regexp.MustCompile(`([A-Z0-9-]+)=(".*?"|[^,]*)`) - split := reg.FindAllStringSubmatch(input, -1) - bandwidth := split[0][2] - bandwidth_int, err := strconv.Atoi(bandwidth) - if err != nil { - panic(err) - } - video := VideoURL{ - URL: url, - Bandwidth: bandwidth_int, - Codecs: split[1][2], - Resolution: split[2][2], - FrameRate: split[3][2], - Audio: split[4][2], - } - return &video - } - return nil -} - -type AudioURL struct { - URL string - MediaType string - GroupId string - Name string - Default string - Autoselect string -} - -func NewAudioURL(input string) *AudioURL { - if strings.HasPrefix(input, "#EXT-X-MEDIA") { - split := strings.Split(strings.Split(input, ":")[1], ",") - url := ParseAttribute(split[5]) - mediaType := ParseAttribute(split[0]) - groupId := ParseAttribute(split[1]) - name := ParseAttribute(split[2]) - default_ := ParseAttribute(split[3]) - autoselect := ParseAttribute(split[4]) - audio := AudioURL{ - URL: url, - MediaType: mediaType, - GroupId: groupId, - Name: name, - Default: default_, - Autoselect: autoselect, - } - return &audio - } - return nil -} diff --git a/media/utils.go b/media/utils.go deleted file mode 100644 index 7160206..0000000 --- a/media/utils.go +++ /dev/null @@ -1,24 +0,0 @@ -package media - -import ( - "strconv" - "strings" -) - -func ParseAttribute(attribute string) string { - split := strings.Split(attribute, "=") - return split[1] -} - -func ResolutionToPixels(resolution string) int { - split := strings.Split(resolution, "x") - width, err := strconv.Atoi(split[0]) - if err != nil { - panic(err) - } - height, err := strconv.Atoi(split[1]) - if err != nil { - panic(err) - } - return width * height -} diff --git a/scratch.txt b/scratch.txt new file mode 100644 index 0000000..00d8545 --- /dev/null +++ b/scratch.txt @@ -0,0 +1,327 @@ +#EXTM3U +#FLOSPORTS:56b3742 +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:7 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-PROGRAM-DATE-TIME:2025-07-12T03:31:41.699Z +#EXTINF:6.006, +media_vo_3225_1752271771877.ts +#EXTINF:6.006, +media_vo_3226_1752271771877.ts +#EXTINF:5.989, +media_vo_3227_1752271771877.ts +#EXTINF:6.006, +media_vo_3228_1752271771877.ts +#EXTINF:6.006, +media_vo_3229_1752271771877.ts +#EXTINF:5.99, +media_vo_3230_1752271771877.ts +#EXTINF:6.006, +media_vo_3231_1752271771877.ts +#EXTINF:6.006, +media_vo_3232_1752271771877.ts +#EXTINF:5.989, +media_vo_3233_1752271771877.ts +#EXTINF:6.006, +media_vo_3234_1752271771877.ts +#EXTINF:6.006, +media_vo_3235_1752271771877.ts +#EXTINF:5.989, +media_vo_3236_1752271771877.ts +#EXTINF:6.006, +media_vo_3237_1752271771877.ts +#EXTINF:5.99, +media_vo_3238_1752271771877.ts +#EXTINF:6.006, +media_vo_3239_1752271771877.ts +#EXTINF:6.006, +media_vo_3240_1752271771877.ts +#EXTINF:5.989, +media_vo_3241_1752271771877.ts +#EXTINF:6.006, +media_vo_3242_1752271771877.ts +#EXTINF:6.006, +media_vo_3243_1752271771877.ts +#EXTINF:5.989, +media_vo_3244_1752271771877.ts +#EXTINF:6.006, +media_vo_3245_1752271771877.ts +#EXTINF:6.006, +media_vo_3246_1752271771877.ts +#EXTINF:5.989, +media_vo_3247_1752271771877.ts +#EXTINF:6.006, +media_vo_3248_1752271771877.ts +#EXTINF:5.99, +media_vo_3249_1752271771877.ts +#EXTINF:6.006, +media_vo_3250_1752271771877.ts +#EXTINF:6.006, +media_vo_3251_1752271771877.ts +#EXTINF:5.989, +media_vo_3252_1752271771877.ts +#EXTINF:6.006, +media_vo_3253_1752271771877.ts +#EXTINF:6.006, +media_vo_3254_1752271771877.ts +#EXTINF:5.989, +media_vo_3255_1752271771877.ts +#EXTINF:6.006, +media_vo_3256_1752271771877.ts +#EXTINF:6.006, +media_vo_3257_1752271771877.ts +#EXTINF:5.99, +media_vo_3258_1752271771877.ts +#EXTINF:6.006, +media_vo_3259_1752271771877.ts +#EXTINF:6.006, +media_vo_3260_1752271771877.ts +#EXTINF:5.989, +media_vo_3261_1752271771877.ts +#EXTINF:6.006, +media_vo_3262_1752271771877.ts +#EXTINF:5.989, +media_vo_3263_1752271771877.ts +#EXTINF:6.006, +media_vo_3264_1752271771877.ts +#EXTINF:6.006, +media_vo_3265_1752271771877.ts +#EXTINF:5.989, +media_vo_3266_1752271771877.ts +#EXTINF:6.006, +media_vo_3267_1752271771877.ts +#EXTINF:6.006, +media_vo_3268_1752271771877.ts +#EXTINF:5.99, +media_vo_3269_1752271771877.ts +#EXTINF:6.006, +media_vo_3270_1752271771877.ts +#EXTINF:6.006, +media_vo_3271_1752271771877.ts +#EXTINF:5.989, +media_vo_3272_1752271771877.ts +#EXTINF:6.006, +media_vo_3273_1752271771877.ts +#EXTINF:6.006, +media_vo_3274_1752271771877.ts +#EXTINF:5.989, +media_vo_3275_1752271771877.ts +#EXTINF:6.006, +media_vo_3276_1752271771877.ts +#EXTINF:5.99, +media_vo_3277_1752271771877.ts +#EXTINF:6.006, +media_vo_3278_1752271771877.ts +#EXTINF:6.006, +media_vo_3279_1752271771877.ts +#EXTINF:5.989, +media_vo_3280_1752271771877.ts +#EXTINF:6.006, +media_vo_3281_1752271771877.ts +#EXTINF:6.006, +media_vo_3282_1752271771877.ts +#EXTINF:5.989, +media_vo_3283_1752271771877.ts +#EXTINF:6.006, +media_vo_3284_1752271771877.ts +#EXTINF:6.006, +media_vo_3285_1752271771877.ts +#EXTINF:5.989, +media_vo_3286_1752271771877.ts +#EXTINF:6.006, +media_vo_3287_1752271771877.ts +#EXTINF:5.99, +media_vo_3288_1752271771877.ts +#EXTINF:6.006, +media_vo_3289_1752271771877.ts +#EXTINF:6.006, +media_vo_3290_1752271771877.ts +#EXTINF:5.989, +media_vo_3291_1752271771877.ts +#EXTINF:6.006, +media_vo_3292_1752271771877.ts +#EXTINF:6.006, +media_vo_3293_1752271771877.ts +#EXTINF:5.989, +media_vo_3294_1752271771877.ts +#EXTINF:6.006, +media_vo_3295_1752271771877.ts +#EXTINF:6.006, +media_vo_3296_1752271771877.ts +#EXTINF:5.99, +media_vo_3297_1752271771877.ts +#EXTINF:6.006, +media_vo_3298_1752271771877.ts +#EXTINF:6.006, +media_vo_3299_1752271771877.ts +#EXTINF:5.989, +media_vo_3300_1752271771877.ts +#EXTINF:6.006, +media_vo_3301_1752271771877.ts +#EXTINF:5.989, +media_vo_3302_1752271771877.ts +#EXTINF:6.006, +media_vo_3303_1752271771877.ts +#EXTINF:6.006, +media_vo_3304_1752271771877.ts +#EXTINF:5.989, +media_vo_3305_1752271771877.ts +#EXTINF:6.006, +media_vo_3306_1752271771877.ts +#EXTINF:6.006, +media_vo_3307_1752271771877.ts +#EXTINF:5.99, +media_vo_3308_1752271771877.ts +#EXTINF:6.006, +media_vo_3309_1752271771877.ts +#EXTINF:6.006, +media_vo_3310_1752271771877.ts +#EXTINF:5.989, +media_vo_3311_1752271771877.ts +#EXTINF:6.006, +media_vo_3312_1752271771877.ts +#EXTINF:6.006, +media_vo_3313_1752271771877.ts +#EXTINF:5.989, +media_vo_3314_1752271771877.ts +#EXTINF:6.006, +media_vo_3315_1752271771877.ts +#EXTINF:5.99, +media_vo_3316_1752271771877.ts +#EXTINF:6.006, +media_vo_3317_1752271771877.ts +#EXTINF:6.006, +media_vo_3318_1752271771877.ts +#EXTINF:5.989, +media_vo_3319_1752271771877.ts +#EXTINF:6.006, +media_vo_3320_1752271771877.ts +#EXTINF:6.006, +media_vo_3321_1752271771877.ts +#EXTINF:5.989, +media_vo_3322_1752271771877.ts +#EXTINF:6.006, +media_vo_3323_1752271771877.ts +#EXTINF:6.006, +media_vo_3324_1752271771877.ts +#EXTINF:5.989, +media_vo_3325_1752271771877.ts +#EXTINF:6.006, +media_vo_3326_1752271771877.ts +#EXTINF:5.99, +media_vo_3327_1752271771877.ts +#EXTINF:6.006, +media_vo_3328_1752271771877.ts +#EXTINF:6.006, +media_vo_3329_1752271771877.ts +#EXTINF:5.989, +media_vo_3330_1752271771877.ts +#EXTINF:6.006, +media_vo_3331_1752271771877.ts +#EXTINF:6.006, +media_vo_3332_1752271771877.ts +#EXTINF:5.989, +media_vo_3333_1752271771877.ts +#EXTINF:6.006, +media_vo_3334_1752271771877.ts +#EXTINF:6.006, +media_vo_3335_1752271771877.ts +#EXTINF:5.99, +media_vo_3336_1752271771877.ts +#EXTINF:6.006, +media_vo_3337_1752271771877.ts +#EXTINF:6.005, +media_vo_3338_1752271771877.ts +#EXTINF:5.99, +media_vo_3339_1752271771877.ts +#EXTINF:6.006, +media_vo_3340_1752271771877.ts +#EXTINF:5.989, +media_vo_3341_1752271771877.ts +#EXTINF:6.006, +media_vo_3342_1752271771877.ts +#EXTINF:6.006, +media_vo_3343_1752271771877.ts +#EXTINF:5.989, +media_vo_3344_1752271771877.ts +#EXTINF:6.006, +media_vo_3345_1752271771877.ts +#EXTINF:6.006, +media_vo_3346_1752271771877.ts +#EXTINF:5.99, +media_vo_3347_1752271771877.ts +#EXTINF:6.006, +media_vo_3348_1752271771877.ts +#EXTINF:6.006, +media_vo_3349_1752271771877.ts +#EXTINF:5.989, +media_vo_3350_1752271771877.ts +#EXTINF:6.006, +media_vo_3351_1752271771877.ts +#EXTINF:6.006, +media_vo_3352_1752271771877.ts +#EXTINF:5.989, +media_vo_3353_1752271771877.ts +#EXTINF:6.006, +media_vo_3354_1752271771877.ts +#EXTINF:5.99, +media_vo_3355_1752271771877.ts +#EXTINF:6.005, +media_vo_3356_1752271771877.ts +#EXTINF:6.006, +media_vo_3357_1752271771877.ts +#EXTINF:5.99, +media_vo_3358_1752271771877.ts +#EXTINF:6.006, +media_vo_3359_1752271771877.ts +#EXTINF:6.006, +media_vo_3360_1752271771877.ts +#EXTINF:5.989, +media_vo_3361_1752271771877.ts +#EXTINF:6.006, +media_vo_3362_1752271771877.ts +#EXTINF:6.006, +media_vo_3363_1752271771877.ts +#EXTINF:5.989, +media_vo_3364_1752271771877.ts +#EXTINF:6.006, +media_vo_3365_1752271771877.ts +#EXTINF:5.99, +media_vo_3366_1752271771877.ts +#EXTINF:6.006, +media_vo_3367_1752271771877.ts +#EXTINF:6.006, +media_vo_3368_1752271771877.ts +#EXTINF:5.989, +media_vo_3369_1752271771877.ts +#EXTINF:6.006, +media_vo_3370_1752271771877.ts +#EXTINF:6.006, +media_vo_3371_1752271771877.ts +#EXTINF:5.989, +media_vo_3372_1752271771877.ts +#EXTINF:6.006, +media_vo_3373_1752271771877.ts +#EXTINF:6.006, +media_vo_3374_1752271771877.ts +#EXTINF:5.989, +media_vo_3375_1752271771877.ts +#EXTINF:6.006, +media_vo_3376_1752271771877.ts +#EXTINF:6.006, +media_vo_3377_1752271771877.ts +#EXTINF:5.99, +media_vo_3378_1752271771877.ts +#EXTINF:6.006, +media_vo_3379_1752271771877.ts +#EXTINF:5.989, +media_vo_3380_1752271771877.ts +#EXTINF:6.006, +media_vo_3381_1752271771877.ts +#EXTINF:6.006, +media_vo_3382_1752271771877.ts +#EXTINF:5.989, +media_vo_3383_1752271771877.ts +#EXTINF:6.006, +media_vo_3384_1752271771877.ts +#EXT-X-ENDLIST \ No newline at end of file