Abstracted to service.go
This commit is contained in:
parent
93b62618a1
commit
b37a312b18
@ -2,34 +2,81 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
downloader2 "m3u8-downloader/pkg/downloader"
|
"m3u8-downloader/pkg/downloader"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
|
//Stream URL
|
||||||
masterUrl := "https://d17cyqyz9yhmep.cloudfront.net/streams/234945/playlist_1752291107574_1752292056713.m3u8"
|
masterUrl := "https://d17cyqyz9yhmep.cloudfront.net/streams/234945/playlist_1752291107574_1752292056713.m3u8"
|
||||||
|
|
||||||
stream, err := downloader2.ParseMasterPlaylist(masterUrl)
|
//Download Service
|
||||||
|
service := downloader.GetDownloadService()
|
||||||
|
|
||||||
|
//Parse Master Playlist -> Stream Set
|
||||||
|
stream, err := service.ParseMasterPlaylist(masterUrl)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
audio, video, err := stream.FetchSegmentPlaylists()
|
//Select best quality streams
|
||||||
|
audio, video := stream.Master.SelectBestQualityStreams()
|
||||||
|
|
||||||
|
//Populate Segment Lists
|
||||||
|
audio_segments, err := service.ParseSegmentPlaylist(stream.BuildSegmentURL(audio.URL))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
audio.Segments = *audio_segments
|
||||||
|
|
||||||
videoPlaylist := downloader2.ParseMediaPlaylist(video)
|
video_segments, err := service.ParseSegmentPlaylist(stream.BuildSegmentURL(video.URL))
|
||||||
audioPlaylist := downloader2.ParseMediaPlaylist(audio)
|
|
||||||
|
|
||||||
for _, segment := range videoPlaylist.Segments {
|
if err != nil {
|
||||||
fmt.Println(segment.URL)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
video.Segments = *video_segments
|
||||||
|
|
||||||
for _, segment := range audioPlaylist.Segments {
|
//Download Segment Playlists
|
||||||
err := downloader2.DownloadTSFile(stream.BuildSegmentURL(segment.URL), downloader2.OutputDirPath)
|
for _, segment := range video.Segments.SegmentList {
|
||||||
|
err := service.DownloadFile(stream.BuildSegmentURL(segment.URL))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, segment := range audio.Segments.SegmentList {
|
||||||
|
err := service.DownloadFile(stream.BuildSegmentURL(segment.URL))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println(stream)
|
||||||
|
|
||||||
|
//stream, err := media.ParseMasterPlaylist(masterUrl)
|
||||||
|
//if err != nil {
|
||||||
|
// panic(err)
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//audio, video, err := stream.FetchSegmentPlaylists()
|
||||||
|
//if err != nil {
|
||||||
|
// panic(err)
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//videoPlaylist := media.ParseMediaPlaylist(video)
|
||||||
|
//audioPlaylist := media.ParseMediaPlaylist(audio)
|
||||||
|
//
|
||||||
|
//for _, segment := range videoPlaylist.SegmentList {
|
||||||
|
// fmt.Println(segment.URL)
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//for _, segment := range audioPlaylist.SegmentList {
|
||||||
|
// err := http.DownloadFile(stream.BuildSegmentURL(segment.URL), media.OutputDirPath)
|
||||||
|
// if err != nil {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//}
|
||||||
}
|
}
|
||||||
|
|||||||
5
pkg/constants/downloader.go
Normal file
5
pkg/constants/downloader.go
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutputDirPath = "./data"
|
||||||
|
)
|
||||||
7
pkg/constants/http.go
Normal file
7
pkg/constants/http.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
const (
|
||||||
|
HTTPUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0"
|
||||||
|
HTTPPrefix = "http://"
|
||||||
|
HTTPSPrefix = "https://"
|
||||||
|
)
|
||||||
11
pkg/constants/media.go
Normal file
11
pkg/constants/media.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
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"
|
||||||
|
PlaylistTypeVOD = "VOD"
|
||||||
|
)
|
||||||
@ -1,131 +0,0 @@
|
|||||||
package downloader
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"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 DownloadTSFile(url string, outputDir string) error {
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileName := path.Base(url)
|
|
||||||
filePath := filepath.Join(outputDir, fileName)
|
|
||||||
data, err := defaultClient.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := os.Create(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
_, err = out.Write(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return 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)
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
package downloader
|
|
||||||
|
|
||||||
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"
|
|
||||||
OutputDirPath = "./data"
|
|
||||||
)
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
package downloader
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
70
pkg/downloader/service.go
Normal file
70
pkg/downloader/service.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package downloader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"m3u8-downloader/pkg/constants"
|
||||||
|
"m3u8-downloader/pkg/http"
|
||||||
|
"m3u8-downloader/pkg/media"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DownloadService struct {
|
||||||
|
client *http.HTTPWrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDownloadService() *DownloadService {
|
||||||
|
return &DownloadService{
|
||||||
|
client: http.DefaultClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DownloadService) GetStreamMetadata(url string) (*media.PlaylistMetadata, error) {
|
||||||
|
return media.GetPlaylistMetadata(url), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DownloadService) ParseSegmentPlaylist(url string) (*media.SegmentPlaylist, error) {
|
||||||
|
content, err := s.client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return media.ParseMediaPlaylist(string(content)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DownloadService) ParseMasterPlaylist(url string) (*media.StreamSet, error) {
|
||||||
|
metadata := media.GetPlaylistMetadata(url)
|
||||||
|
content, err := s.client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
video, audio := media.ParsePlaylistLines(string(content))
|
||||||
|
master := media.NewMasterPlaylist(video, audio)
|
||||||
|
return media.NewStreamSet(metadata, master), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DownloadService) DownloadFile(url string) error {
|
||||||
|
fmt.Println("Downloading: " + url)
|
||||||
|
if err := os.MkdirAll(constants.OutputDirPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := path.Base(url)
|
||||||
|
filePath := filepath.Join(constants.OutputDirPath, fileName)
|
||||||
|
data, err := s.client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = out.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -1,98 +0,0 @@
|
|||||||
package downloader
|
|
||||||
|
|
||||||
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 downloader 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
|
|
||||||
}
|
|
||||||
64
pkg/http/client.go
Normal file
64
pkg/http/client.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"m3u8-downloader/pkg/constants"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
Get(url string) ([]byte, error)
|
||||||
|
GetWithContext(ctx context.Context, url string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPWrapper struct {
|
||||||
|
client *http.Client
|
||||||
|
headers map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPWrapper) Get(url string) ([]byte, error) {
|
||||||
|
return c.GetWithContext(context.Background(), url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPWrapper) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
var DefaultClient = &HTTPWrapper{
|
||||||
|
client: &http.Client{},
|
||||||
|
headers: map[string]string{
|
||||||
|
"User-Agent": constants.HTTPUserAgent,
|
||||||
|
},
|
||||||
|
}
|
||||||
10
pkg/http/utils.go
Normal file
10
pkg/http/utils.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"m3u8-downloader/pkg/constants"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateURL(url string) bool {
|
||||||
|
return strings.HasPrefix(url, constants.HTTPPrefix) || strings.HasPrefix(url, constants.HTTPSPrefix)
|
||||||
|
}
|
||||||
41
pkg/media/master_playlist.go
Normal file
41
pkg/media/master_playlist.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
type MasterPlaylist struct {
|
||||||
|
VideoFeeds []VideoPlaylist
|
||||||
|
AudioFeeds []AudioPlaylist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMasterPlaylist(videoFeeds []VideoPlaylist, audioFeeds []AudioPlaylist) *MasterPlaylist {
|
||||||
|
return &MasterPlaylist{
|
||||||
|
VideoFeeds: videoFeeds,
|
||||||
|
AudioFeeds: audioFeeds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MasterPlaylist) SelectBestQualityStreams() (*VideoPlaylist, *AudioPlaylist) {
|
||||||
|
if len(m.VideoFeeds) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bestVideo := m.VideoFeeds[0]
|
||||||
|
maxPixels := ResolutionToPixels(bestVideo.Resolution)
|
||||||
|
|
||||||
|
for _, video := range m.VideoFeeds {
|
||||||
|
pixels := ResolutionToPixels(video.Resolution)
|
||||||
|
if video.Bandwidth > bestVideo.Bandwidth || pixels > maxPixels {
|
||||||
|
bestVideo = video
|
||||||
|
maxPixels = pixels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &bestVideo, m.FindAudioFeedByGroup(bestVideo.AudioGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MasterPlaylist) FindAudioFeedByGroup(groupID string) *AudioPlaylist {
|
||||||
|
for _, audio := range m.AudioFeeds {
|
||||||
|
if audio.GroupID == groupID {
|
||||||
|
return &audio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
139
pkg/media/media_playlist.go
Normal file
139
pkg/media/media_playlist.go
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"m3u8-downloader/pkg/constants"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VideoPlaylist struct {
|
||||||
|
URL string
|
||||||
|
Bandwidth int
|
||||||
|
Codecs string
|
||||||
|
Resolution string
|
||||||
|
FrameRate string
|
||||||
|
AudioGroup string
|
||||||
|
Segments SegmentPlaylist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVideoStream(streamInfo, url string) (*VideoPlaylist, error) {
|
||||||
|
if !strings.HasPrefix(streamInfo, constants.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 &VideoPlaylist{
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VideoPlaylist) BuildPlaylistURL(filename string) string {
|
||||||
|
return fmt.Sprintf("%s%s", constants.HTTPSPrefix, v.URL+"/a/5000/"+filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioPlaylist struct {
|
||||||
|
URL string
|
||||||
|
MediaType string
|
||||||
|
GroupID string
|
||||||
|
Name string
|
||||||
|
IsDefault bool
|
||||||
|
AutoSelect bool
|
||||||
|
Segments SegmentPlaylist
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AudioPlaylist) BuildPlaylistURL(filename string) string {
|
||||||
|
return fmt.Sprintf("%s%s", constants.HTTPSPrefix, a.URL+"/a/5000/"+filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAudioStream(mediaInfo string) (*AudioPlaylist, error) {
|
||||||
|
if !strings.HasPrefix(mediaInfo, constants.ExtXMedia) {
|
||||||
|
return nil, errors.New("invalid downloader info line")
|
||||||
|
}
|
||||||
|
|
||||||
|
attributes := ParseMediaAttributes(mediaInfo)
|
||||||
|
|
||||||
|
return &AudioPlaylist{
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaylistMetadata struct {
|
||||||
|
URL string
|
||||||
|
Domain string
|
||||||
|
StreamID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPlaylistMetadata(masterURL string) *PlaylistMetadata {
|
||||||
|
strippedURL := strings.Replace(masterURL, constants.HTTPSPrefix, "", 1)
|
||||||
|
strippedURL = strings.Replace(strippedURL, constants.HTTPPrefix, "", 1)
|
||||||
|
urlPrefix := strings.Split(strippedURL, "/")[0]
|
||||||
|
|
||||||
|
return &PlaylistMetadata{
|
||||||
|
URL: masterURL,
|
||||||
|
Domain: urlPrefix,
|
||||||
|
StreamID: strings.Split(strippedURL, "/")[2],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParsePlaylistLines(content string) ([]VideoPlaylist, []AudioPlaylist) {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
var videoStreams []VideoPlaylist
|
||||||
|
var audioStreams []AudioPlaylist
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.HasPrefix(line, constants.ExtXStreamInf) && i+1 < len(lines) {
|
||||||
|
if video, err := NewVideoStream(line, lines[i+1]); err == nil {
|
||||||
|
videoStreams = append(videoStreams, *video)
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(line, constants.ExtXMedia) {
|
||||||
|
if audio, err := NewAudioStream(line); err == nil {
|
||||||
|
audioStreams = append(audioStreams, *audio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return videoStreams, audioStreams
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
package downloader
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"m3u8-downloader/pkg/constants"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -17,30 +17,11 @@ func StripQuotes(input string) string {
|
|||||||
return strings.Trim(input, "\"")
|
return strings.Trim(input, "\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolutionToPixels(resolution string) int {
|
func ParseMediaAttributes(mediaInfo string) map[string]string {
|
||||||
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)
|
attributes := make(map[string]string)
|
||||||
|
|
||||||
// Remove the #EXT-X-MEDIA: prefix
|
// Remove the #EXT-X-MEDIA: prefix
|
||||||
content := strings.TrimPrefix(mediaInfo, ExtXMedia+":")
|
content := strings.TrimPrefix(mediaInfo, constants.ExtXMedia+":")
|
||||||
|
|
||||||
// Split by comma, but respect quoted values
|
// Split by comma, but respect quoted values
|
||||||
parts := splitRespectingQuotes(content, ',')
|
parts := splitRespectingQuotes(content, ',')
|
||||||
@ -82,7 +63,3 @@ func splitRespectingQuotes(input string, delimiter rune) []string {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateURL(url string) bool {
|
|
||||||
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
|
|
||||||
}
|
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package downloader
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"m3u8-downloader/pkg/constants"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -13,47 +13,47 @@ type Segment struct {
|
|||||||
Sequence int
|
Sequence int
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaPlaylist struct {
|
type SegmentPlaylist struct {
|
||||||
Segments []Segment
|
SegmentList []Segment
|
||||||
TargetDuration float64
|
TargetDuration float64
|
||||||
IsLive bool
|
IsLive bool
|
||||||
HasEndList bool
|
HasEndList bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StreamSet) FetchSegmentPlaylists() (videoPlaylist, audioPlaylist string, err error) {
|
//func (s *StreamSet) FetchSegmentPlaylists() (videoPlaylist, audioPlaylist string, err error) {
|
||||||
video, audio := s.SelectBestQualityStreams()
|
// video, audio := s.SelectBestQualityStreams()
|
||||||
if video == nil {
|
// if video == nil {
|
||||||
return "", "", errors.New("no video streams available")
|
// return "", "", errors.New("no VideoFeeds streams available")
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
videoURL := s.BuildStreamURL(video.URL)
|
// videoURL := s.BuildStreamURL(video.URL)
|
||||||
|
//
|
||||||
videoContent, err := FetchPlaylistContent(StripQuotes(videoURL))
|
// videoContent, err := http.FetchPlaylistContent(StripQuotes(videoURL))
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
return "", "", err
|
// return "", "", err
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
var audioContent string
|
// var audioContent string
|
||||||
if audio != nil {
|
// if audio != nil {
|
||||||
audioURL := s.BuildStreamURL(StripQuotes(audio.URL))
|
// audioURL := s.BuildStreamURL(StripQuotes(audio.URL))
|
||||||
audioContent, err = FetchPlaylistContent(StripQuotes(audioURL))
|
// audioContent, err = http.FetchPlaylistContent(StripQuotes(audioURL))
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
return "", "", err
|
// return "", "", err
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
return videoContent, audioContent, nil
|
// return videoContent, audioContent, nil
|
||||||
}
|
//}
|
||||||
|
|
||||||
func (s *StreamSet) BuildSegmentURL(filename string) string {
|
func (s *StreamSet) BuildSegmentURL(filename string) string {
|
||||||
return fmt.Sprintf("%s%s", HTTPSPrefix, s.Metadata.Domain+"/streams/"+s.Metadata.StreamID+"/a/5000/"+filename)
|
return fmt.Sprintf("%s%s", constants.HTTPSPrefix, s.Metadata.Domain+"/streams/"+s.Metadata.StreamID+"/a/5000/"+filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseMediaPlaylist(content string) *MediaPlaylist {
|
func ParseMediaPlaylist(content string) *SegmentPlaylist {
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
var segments []Segment
|
var segments []Segment
|
||||||
|
|
||||||
playlist := &MediaPlaylist{
|
playlist := &SegmentPlaylist{
|
||||||
IsLive: true,
|
IsLive: true,
|
||||||
HasEndList: false,
|
HasEndList: false,
|
||||||
}
|
}
|
||||||
@ -62,10 +62,10 @@ func ParseMediaPlaylist(content string) *MediaPlaylist {
|
|||||||
|
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(line, ExtXTargetDuration):
|
case strings.HasPrefix(line, constants.ExtXTargetDuration):
|
||||||
playlist.TargetDuration = parseTargetDuration(line)
|
playlist.TargetDuration = parseTargetDuration(line)
|
||||||
|
|
||||||
case strings.HasPrefix(line, ExtInf):
|
case strings.HasPrefix(line, constants.ExtInf):
|
||||||
if i+1 < len(lines) {
|
if i+1 < len(lines) {
|
||||||
duration := parseSegmentDuration(line)
|
duration := parseSegmentDuration(line)
|
||||||
segments = append(segments, Segment{
|
segments = append(segments, Segment{
|
||||||
@ -76,17 +76,17 @@ func ParseMediaPlaylist(content string) *MediaPlaylist {
|
|||||||
mediaSequence++
|
mediaSequence++
|
||||||
}
|
}
|
||||||
|
|
||||||
case strings.HasPrefix(line, ExtXPlaylistType):
|
case strings.HasPrefix(line, constants.ExtXPlaylistType):
|
||||||
if strings.Contains(line, PlaylistTypeVOD) {
|
if strings.Contains(line, constants.PlaylistTypeVOD) {
|
||||||
playlist.IsLive = false
|
playlist.IsLive = false
|
||||||
}
|
}
|
||||||
|
|
||||||
case strings.HasPrefix(line, ExtXEndList):
|
case strings.HasPrefix(line, constants.ExtXEndList):
|
||||||
playlist.HasEndList = true
|
playlist.HasEndList = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playlist.Segments = segments
|
playlist.SegmentList = segments
|
||||||
return playlist
|
return playlist
|
||||||
}
|
}
|
||||||
|
|
||||||
22
pkg/media/stream.go
Normal file
22
pkg/media/stream.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"m3u8-downloader/pkg/constants"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StreamSet struct {
|
||||||
|
Metadata *PlaylistMetadata
|
||||||
|
Master *MasterPlaylist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStreamSet(metadata *PlaylistMetadata, master *MasterPlaylist) *StreamSet {
|
||||||
|
return &StreamSet{
|
||||||
|
Metadata: metadata,
|
||||||
|
Master: master,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StreamSet) BuildPlaylistURL(url string) string {
|
||||||
|
return fmt.Sprintf("%s%s", constants.HTTPSPrefix, s.Metadata.Domain+"/streams/"+s.Metadata.StreamID+"/"+url)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user