mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-06 08:30:29 +00:00
480 lines
14 KiB
Go
480 lines
14 KiB
Go
package hls
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Variant is one rendition entry of a master playlist.
|
|
type Variant struct {
|
|
URI string
|
|
Bandwidth int64
|
|
AvgBandwidth int64
|
|
Resolution string
|
|
Codecs string
|
|
Name string
|
|
AudioGroup string // AUDIO attribute: group id of the audio renditions
|
|
}
|
|
|
|
// MediaRendition is one EXT-X-MEDIA entry of a master playlist. Only the
|
|
// fields needed to detect unsupported track layouts are kept.
|
|
type MediaRendition struct {
|
|
Type string
|
|
GroupID string
|
|
URI string
|
|
}
|
|
|
|
// MasterPlaylist is a parsed master playlist with its rendition groups.
|
|
type MasterPlaylist struct {
|
|
Variants []*Variant
|
|
Rendition map[string][]*MediaRendition // group id -> all EXT-X-MEDIA members
|
|
}
|
|
|
|
// Key is the decryption key applied to the segments that follow its tag, until
|
|
// the next EXT-X-KEY tag or the end of the playlist.
|
|
type Key struct {
|
|
Method string
|
|
URI string // absolute URI
|
|
IV []byte // explicit IV; nil means derive from the media sequence number
|
|
}
|
|
|
|
// ByteRange is an EXT-X-BYTERANGE sub range of the segment resource.
|
|
type ByteRange struct {
|
|
Length int64
|
|
Offset int64
|
|
}
|
|
|
|
// Segment is one downloadable piece of a media playlist. The init section of
|
|
// an fMP4 stream is represented as a segment with IsInit set.
|
|
type Segment struct {
|
|
URI string
|
|
Sequence int64 // media sequence number, used to derive the default AES IV
|
|
Byterange *ByteRange
|
|
Key *Key // nil means unencrypted
|
|
IsInit bool
|
|
Gap bool
|
|
Duration float64
|
|
Size int64 // exact size in bytes when known (byterange / HEAD probe), 0 when unknown
|
|
}
|
|
|
|
// Media is a parsed media playlist.
|
|
type Media struct {
|
|
Segments []*Segment
|
|
Version int
|
|
Live bool // no EXT-X-ENDLIST tag
|
|
IsFMP4 bool // playlist declares an init section via EXT-X-MAP
|
|
Discontinuity bool // playlist contains EXT-X-DISCONTINUITY
|
|
}
|
|
|
|
const (
|
|
keyMethodNone = "NONE"
|
|
keyMethodAES128 = "AES-128"
|
|
keyMethodSampleAES = "SAMPLE-AES"
|
|
)
|
|
|
|
// IsMasterPlaylist reports whether the content looks like a master playlist.
|
|
func IsMasterPlaylist(content string) bool {
|
|
return strings.Contains(content, "#EXT-X-STREAM-INF")
|
|
}
|
|
|
|
func validateM3U8Header(content string) error {
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if line != "#EXTM3U" {
|
|
return errors.New("content is not a valid m3u8 playlist")
|
|
}
|
|
return nil
|
|
}
|
|
return errors.New("empty m3u8 playlist")
|
|
}
|
|
|
|
// ParseMaster parses a master playlist and returns all variants.
|
|
func ParseMaster(content string, baseURL *url.URL) ([]*Variant, error) {
|
|
master, err := ParseMasterPlaylist(content, baseURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return master.Variants, nil
|
|
}
|
|
|
|
// ParseMasterPlaylist parses a master playlist including its EXT-X-MEDIA
|
|
// rendition groups.
|
|
func ParseMasterPlaylist(content string, baseURL *url.URL) (*MasterPlaylist, error) {
|
|
if err := validateM3U8Header(content); err != nil {
|
|
return nil, err
|
|
}
|
|
master := &MasterPlaylist{Rendition: make(map[string][]*MediaRendition)}
|
|
var pending *Variant
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "#EXT-X-STREAM-INF:") {
|
|
attrs := parseAttributes(line[len("#EXT-X-STREAM-INF:"):])
|
|
pending = &Variant{
|
|
Bandwidth: parseInt64(attrs["BANDWIDTH"]),
|
|
AvgBandwidth: parseInt64(attrs["AVERAGE-BANDWIDTH"]),
|
|
Resolution: attrs["RESOLUTION"],
|
|
Codecs: unquote(attrs["CODECS"]),
|
|
Name: unquote(attrs["NAME"]),
|
|
AudioGroup: unquote(attrs["AUDIO"]),
|
|
}
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "#EXT-X-MEDIA:") {
|
|
attrs := parseAttributes(line[len("#EXT-X-MEDIA:"):])
|
|
rendition := &MediaRendition{
|
|
Type: strings.ToUpper(attrs["TYPE"]),
|
|
GroupID: unquote(attrs["GROUP-ID"]),
|
|
}
|
|
// Only an explicit URI means a separate track; resolving an empty
|
|
// reference would fabricate one out of the playlist URL.
|
|
if uri := unquote(attrs["URI"]); uri != "" {
|
|
rendition.URI = resolveURL(baseURL, uri)
|
|
}
|
|
if rendition.GroupID != "" {
|
|
// Keep every member: a group is unsupported as a whole when
|
|
// ANY of its members carries its own URI.
|
|
master.Rendition[rendition.GroupID] = append(master.Rendition[rendition.GroupID], rendition)
|
|
}
|
|
continue
|
|
}
|
|
if pending != nil {
|
|
if line[0] != '#' {
|
|
pending.URI = resolveURL(baseURL, line)
|
|
master.Variants = append(master.Variants, pending)
|
|
}
|
|
pending = nil
|
|
}
|
|
}
|
|
if len(master.Variants) == 0 {
|
|
return nil, errors.New("master playlist has no variants")
|
|
}
|
|
return master, nil
|
|
}
|
|
|
|
// checkAudioRendition rejects the selected variant when its AUDIO group
|
|
// carries a separate audio track: downloading only the video variant would
|
|
// silently produce a silent file. Groups the variant does not reference are
|
|
// ignored (variants without an AUDIO attribute are assumed self-contained).
|
|
func checkAudioRendition(master *MasterPlaylist, best *Variant) error {
|
|
if best == nil || best.AudioGroup == "" {
|
|
return nil
|
|
}
|
|
for _, rendition := range master.Rendition[best.AudioGroup] {
|
|
if rendition == nil || rendition.Type != "AUDIO" || rendition.URI == "" {
|
|
continue
|
|
}
|
|
return fmt.Errorf(
|
|
"selected variant uses a separate audio rendition (group %q), which is not supported: the merged output would have no audio",
|
|
best.AudioGroup)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PickBestVariant selects the highest-bandwidth variant.
|
|
func PickBestVariant(variants []*Variant) *Variant {
|
|
var best *Variant
|
|
for _, v := range variants {
|
|
if best == nil || v.Bandwidth > best.Bandwidth {
|
|
best = v
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// ParseMedia parses a media playlist into an ordered segment plan.
|
|
func ParseMedia(content string, baseURL *url.URL) (*Media, error) {
|
|
if err := validateM3U8Header(content); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
media := &Media{Live: true}
|
|
var currentKey *Key
|
|
var pendingByterange *ByteRange
|
|
var pendingGap bool
|
|
var pendingDuration float64
|
|
var lastRangeEnd int64
|
|
var sequence int64
|
|
var mapIdentity string // normalized identity of the current EXT-X-MAP
|
|
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if line[0] != '#' {
|
|
// Every non-comment line in a media playlist is a segment URI.
|
|
seg := &Segment{
|
|
URI: resolveURL(baseURL, line),
|
|
Sequence: sequence,
|
|
Byterange: pendingByterange,
|
|
Key: currentKey,
|
|
Gap: pendingGap,
|
|
Duration: pendingDuration,
|
|
}
|
|
if pendingByterange != nil {
|
|
// Byterange segments have exact sizes straight from the playlist.
|
|
seg.Size = pendingByterange.Length
|
|
}
|
|
sequence++
|
|
pendingByterange = nil
|
|
pendingGap = false
|
|
pendingDuration = 0
|
|
media.Segments = append(media.Segments, seg)
|
|
continue
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(line, "#EXTINF:"):
|
|
value := line[len("#EXTINF:"):]
|
|
if idx := strings.Index(value, ","); idx >= 0 {
|
|
value = value[:idx]
|
|
}
|
|
pendingDuration = parseFloat(value)
|
|
case strings.HasPrefix(line, "#EXT-X-KEY:"):
|
|
key, err := parseKey(line[len("#EXT-X-KEY:"):], baseURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
currentKey = key
|
|
case strings.HasPrefix(line, "#EXT-X-MAP:"):
|
|
attrs := parseAttributes(line[len("#EXT-X-MAP:"):])
|
|
uriAttr := unquote(attrs["URI"])
|
|
if uriAttr == "" {
|
|
return nil, errors.New("EXT-X-MAP without URI is not supported")
|
|
}
|
|
resolved := resolveURL(baseURL, uriAttr)
|
|
// The init section identity covers every attribute, not just the
|
|
// URI: a repeated tag with a different BYTERANGE addresses
|
|
// different bytes and changes the init section. NUL keeps the
|
|
// two parts unambiguously separated.
|
|
identity := resolved + "\x00" + attrs["BYTERANGE"]
|
|
if mapIdentity != "" {
|
|
if mapIdentity != identity {
|
|
return nil, errors.New("changing init sections are not supported")
|
|
}
|
|
media.IsFMP4 = true
|
|
continue
|
|
}
|
|
mapIdentity = identity
|
|
init := &Segment{
|
|
URI: resolved,
|
|
Sequence: sequence,
|
|
IsInit: true,
|
|
Key: currentKey,
|
|
}
|
|
if attrs["BYTERANGE"] != "" {
|
|
init.Byterange = parseByteRange(attrs["BYTERANGE"], 0)
|
|
if init.Byterange == nil {
|
|
return nil, fmt.Errorf("invalid EXT-X-MAP BYTERANGE: %s", attrs["BYTERANGE"])
|
|
}
|
|
}
|
|
if init.Key != nil && init.Key.IV == nil {
|
|
// RFC 8216 only defines sequence-derived IVs for media
|
|
// segments; guessing one for the init section produces
|
|
// corrupt output, so require the playlist to be explicit.
|
|
return nil, errors.New("encrypted EXT-X-MAP without an explicit IV is not supported")
|
|
}
|
|
// The init section is prepended to the segment plan, it takes
|
|
// the media sequence number of the playlist for IV derivation.
|
|
media.Segments = append(media.Segments, init)
|
|
media.IsFMP4 = true
|
|
case strings.HasPrefix(line, "#EXT-X-BYTERANGE:"):
|
|
pendingByterange = parseByteRange(line[len("#EXT-X-BYTERANGE:"):], lastRangeEnd)
|
|
if pendingByterange == nil {
|
|
return nil, fmt.Errorf("invalid EXT-X-BYTERANGE: %s", line)
|
|
}
|
|
case line == "#EXT-X-GAP":
|
|
pendingGap = true
|
|
case line == "#EXT-X-DISCONTINUITY":
|
|
media.Discontinuity = true
|
|
case line == "#EXT-X-ENDLIST":
|
|
media.Live = false
|
|
case strings.HasPrefix(line, "#EXT-X-MEDIA-SEQUENCE:"):
|
|
sequence = parseInt64(line[len("#EXT-X-MEDIA-SEQUENCE:"):])
|
|
case strings.HasPrefix(line, "#EXT-X-VERSION:"):
|
|
media.Version = int(parseInt64(line[len("#EXT-X-VERSION:"):]))
|
|
}
|
|
|
|
if pendingByterange != nil {
|
|
// Non-segment tags between BYTERANGE and its URI must not reset the
|
|
// running offset, so it is tracked here after each parse.
|
|
lastRangeEnd = pendingByterange.Offset + pendingByterange.Length
|
|
}
|
|
}
|
|
|
|
if len(media.Segments) == 0 {
|
|
return nil, errors.New("media playlist has no segments")
|
|
}
|
|
return media, nil
|
|
}
|
|
|
|
// validateSupported rejects playlist features the byte-concatenation merge
|
|
// cannot reproduce faithfully. Skipping such features silently would report
|
|
// success while producing a broken file.
|
|
func validateSupported(media *Media) error {
|
|
if media.Live {
|
|
return errors.New("live streams are not supported yet")
|
|
}
|
|
if media.Discontinuity {
|
|
return errors.New("EXT-X-DISCONTINUITY is not supported: the merged output would have a broken timeline")
|
|
}
|
|
for _, seg := range media.Segments {
|
|
if seg.Gap {
|
|
return errors.New("EXT-X-GAP is not supported: the missing segment would leave a hole in the merged output")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseKey(attrs string, baseURL *url.URL) (*Key, error) {
|
|
parsed := parseAttributes(attrs)
|
|
switch method := parsed["METHOD"]; method {
|
|
case keyMethodNone:
|
|
return nil, nil
|
|
case keyMethodAES128:
|
|
uri := unquote(parsed["URI"])
|
|
if uri == "" {
|
|
return nil, errors.New("AES-128 EXT-X-KEY without URI")
|
|
}
|
|
if format := unquote(parsed["KEYFORMAT"]); format != "" && format != "identity" {
|
|
return nil, fmt.Errorf("unsupported EXT-X-KEY KEYFORMAT: %s", format)
|
|
}
|
|
key := &Key{Method: keyMethodAES128, URI: resolveURL(baseURL, uri)}
|
|
if iv, ok := parsed["IV"]; ok {
|
|
value := strings.TrimPrefix(strings.ToLower(iv), "0x")
|
|
decoded, err := hex.DecodeString(value)
|
|
if err != nil || len(decoded) != 16 {
|
|
return nil, fmt.Errorf("invalid EXT-X-KEY IV: %s", iv)
|
|
}
|
|
key.IV = decoded
|
|
}
|
|
return key, nil
|
|
case keyMethodSampleAES:
|
|
return nil, errors.New("SAMPLE-AES encryption is not supported")
|
|
default:
|
|
return nil, fmt.Errorf("unsupported encryption method: %s", method)
|
|
}
|
|
}
|
|
|
|
func parseByteRange(value string, defaultOffset int64) *ByteRange {
|
|
lengthPart := value
|
|
offsetPart := ""
|
|
if idx := strings.Index(value, "@"); idx >= 0 {
|
|
lengthPart = value[:idx]
|
|
offsetPart = value[idx+1:]
|
|
}
|
|
length := parseInt64(lengthPart)
|
|
if length <= 0 {
|
|
return nil
|
|
}
|
|
offset := defaultOffset
|
|
if offsetPart != "" {
|
|
offset = parseInt64(offsetPart)
|
|
if offset < 0 {
|
|
return nil
|
|
}
|
|
}
|
|
return &ByteRange{Length: length, Offset: offset}
|
|
}
|
|
|
|
// resolveURL resolves a playlist URI reference against the playlist URL
|
|
// following RFC 3986.
|
|
func resolveURL(baseURL *url.URL, ref string) string {
|
|
refURL, err := url.Parse(ref)
|
|
if err != nil {
|
|
return ref
|
|
}
|
|
return baseURL.ResolveReference(refURL).String()
|
|
}
|
|
|
|
// parseAttributes parses an HLS attribute list like FOO="bar",NUM=42.
|
|
func parseAttributes(s string) map[string]string {
|
|
attrs := make(map[string]string)
|
|
var key, val strings.Builder
|
|
inKey := true
|
|
inQuote := false
|
|
flush := func() {
|
|
name := strings.ToUpper(strings.TrimSpace(key.String()))
|
|
if name != "" {
|
|
attrs[name] = strings.TrimSpace(val.String())
|
|
}
|
|
key.Reset()
|
|
val.Reset()
|
|
inKey = true
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if inKey {
|
|
if c == '=' {
|
|
inKey = false
|
|
} else {
|
|
key.WriteByte(c)
|
|
}
|
|
continue
|
|
}
|
|
if c == '"' {
|
|
inQuote = !inQuote
|
|
continue
|
|
}
|
|
if c == ',' && !inQuote {
|
|
flush()
|
|
continue
|
|
}
|
|
val.WriteByte(c)
|
|
}
|
|
flush()
|
|
return attrs
|
|
}
|
|
|
|
func unquote(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) >= 2 && strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func parseInt64(s string) int64 {
|
|
value, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
|
return value
|
|
}
|
|
|
|
func parseFloat(s string) float64 {
|
|
value, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
|
return value
|
|
}
|
|
|
|
// OutputExtension returns the container file extension for the playlist.
|
|
func (m *Media) OutputExtension() string {
|
|
if m.IsFMP4 {
|
|
return ".mp4"
|
|
}
|
|
return ".ts"
|
|
}
|
|
|
|
// DeriveOutputName builds the merged output file name from a playlist URL.
|
|
func DeriveOutputName(playlistURL string, media *Media) string {
|
|
name := ""
|
|
if u, err := url.Parse(playlistURL); err == nil {
|
|
name = path.Base(u.Path)
|
|
name = strings.TrimSuffix(name, path.Ext(name))
|
|
}
|
|
if name == "" || name == "/" || name == "." {
|
|
if u, err := url.Parse(playlistURL); err == nil {
|
|
name = u.Hostname()
|
|
}
|
|
}
|
|
return name + media.OutputExtension()
|
|
}
|