Files

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()
}