504 lines
15 KiB
Go
504 lines
15 KiB
Go
package m3u8
|
|
|
|
import (
|
|
"91porn-server/app/appg"
|
|
"91porn-server/common"
|
|
"91porn-server/common/constant"
|
|
"91porn-server/common/constant/redisconst"
|
|
"91porn-server/common/httputil"
|
|
"91porn-server/common/log"
|
|
"91porn-server/common/stderr"
|
|
"91porn-server/models/commod"
|
|
"91porn-server/web/webg"
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/grafov/m3u8"
|
|
)
|
|
|
|
func GetAPPM3u8(source, fileName, _ string, cdn string, fsIo func(source, mds string) (data []byte, err error)) (*bytes.Buffer, error) {
|
|
mds := GetMediaResouce(source)
|
|
key := m3u8PureCacheKey(source, mds)
|
|
m3u8redis, err := appg.Redis.Get(key)
|
|
var m3u8Byte []byte
|
|
if m3u8redis != nil && err == nil {
|
|
//判断是否是m3u8文件
|
|
if IsM3u8([]byte(*m3u8redis)) {
|
|
m3u8Byte = []byte(*m3u8redis)
|
|
} else {
|
|
_, _ = appg.Redis.Del(key)
|
|
}
|
|
}
|
|
if mds == constant.MediaSourceLaoSiJi {
|
|
source = normalizeLaosijiM3u8Source(source)
|
|
}
|
|
if m3u8Byte == nil || len(m3u8Byte) == 0 {
|
|
m3u8Byte, err = fsIo(source, mds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = appg.Redis.Set(key, string(m3u8Byte), redisconst.M3u8CacheExpire)
|
|
}
|
|
// TS 分片鉴权签名密钥:优先取配置(appg.Conf.Base.TsAuth),未配置时回退内置默认(版本 default + 内置常量)
|
|
keyVersion, authKey := appg.Conf.Base.TsAuth.Resolve()
|
|
var bytebuff *bytes.Buffer
|
|
if mds == constant.MediaSourcePMS {
|
|
bytebuff = DecodeFromReader(m3u8Byte, "", "/api/app/vid", "", mds, authKey, keyVersion, source)
|
|
} else if mds == constant.MediaSourceSP {
|
|
bytebuff = DecodeFromReader(m3u8Byte, cdn+strings.TrimSuffix(source, fileName), "/api/app/vid/sec", "", mds, authKey, keyVersion, source)
|
|
} else if mds == constant.MediaSourceLaoSiJi {
|
|
bytebuff = DecodeFromReader(m3u8Byte, cdn, "/api/app/vid/lsjsec", "", mds, authKey, keyVersion, source)
|
|
} else if mds == constant.MediaSourceJH1B {
|
|
bytebuff = DecodeFromReader(m3u8Byte, cdn+strings.TrimSuffix(source, fileName), "/api/app/vid/m3u8sec", "", mds, authKey, keyVersion, source)
|
|
}
|
|
if bytebuff == nil {
|
|
log.Warn("can't create m3u8 file", log.Any("source", source))
|
|
return nil, errors.New(stderr.CodeEmptyData.Msg())
|
|
}
|
|
return bytebuff, nil
|
|
}
|
|
|
|
func normalizeLaosijiM3u8Source(source string) string {
|
|
source = strings.TrimLeft(strings.TrimSpace(source), "/")
|
|
return strings.TrimPrefix(source, "laosiji/")
|
|
}
|
|
|
|
// m3u8PureCacheKey isolates raw playlists by media source and their complete
|
|
// normalized source path. Hashing keeps the Redis key bounded while avoiding
|
|
// collisions between common basenames such as index.m3u8.
|
|
func m3u8PureCacheKey(source, mds string) string {
|
|
normalizedSource := normalizeM3u8CacheSource(source)
|
|
sum := sha256.Sum256([]byte(mds + "\x00" + normalizedSource))
|
|
return redisconst.M3u8PureCacheFmt(mds + ":" + hex.EncodeToString(sum[:]))
|
|
}
|
|
|
|
func normalizeM3u8CacheSource(source string) string {
|
|
source = strings.TrimSpace(source)
|
|
parsed, err := url.Parse(source)
|
|
if err != nil {
|
|
return path.Clean("/" + strings.TrimLeft(strings.ReplaceAll(source, "\\", "/"), "/"))
|
|
}
|
|
|
|
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
|
parsed.Host = strings.ToLower(parsed.Host)
|
|
parsed.Fragment = ""
|
|
parsed.Path = path.Clean("/" + strings.TrimLeft(strings.ReplaceAll(parsed.Path, "\\", "/"), "/"))
|
|
parsed.RawPath = ""
|
|
if parsed.RawQuery != "" {
|
|
if query, queryErr := url.ParseQuery(parsed.RawQuery); queryErr == nil {
|
|
parsed.RawQuery = query.Encode()
|
|
}
|
|
}
|
|
return parsed.String()
|
|
}
|
|
|
|
func DecodeFromReader(reader []byte, cdnUrl, serUrl, key, mds, authKey, keyVersion, source string) *bytes.Buffer {
|
|
p, listType, err := m3u8.DecodeFrom(bytes.NewReader(reader), false)
|
|
if err != nil {
|
|
log.Error("m3u8 decodeFrom error", log.E(err))
|
|
return nil
|
|
}
|
|
switch listType {
|
|
case m3u8.MEDIA:
|
|
return Create(p.(*m3u8.MediaPlaylist), cdnUrl, serUrl, key, mds, authKey, keyVersion, source)
|
|
case m3u8.MASTER:
|
|
return p.(*m3u8.MasterPlaylist).Encode()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RewriteMasterPlaylist rewrites every child playlist URI in a master HLS
|
|
// playlist. Media playlists are returned unchanged. The signed H.265 pull
|
|
// endpoint uses this so variants and alternate renditions do not lose their
|
|
// HMAC when the cloud transcoder follows a relative child URI.
|
|
func RewriteMasterPlaylist(reader []byte, rewrite func(string) (string, error)) (*bytes.Buffer, error) {
|
|
if rewrite == nil {
|
|
return bytes.NewBuffer(reader), nil
|
|
}
|
|
playlist, listType, err := m3u8.DecodeFrom(bytes.NewReader(reader), false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if listType != m3u8.MASTER {
|
|
return bytes.NewBuffer(reader), nil
|
|
}
|
|
|
|
master := playlist.(*m3u8.MasterPlaylist)
|
|
rewritten := make(map[string]string)
|
|
rewriteURI := func(rawURI string) (string, error) {
|
|
rawURI = strings.TrimSpace(rawURI)
|
|
if rawURI == "" {
|
|
return "", nil
|
|
}
|
|
if value, ok := rewritten[rawURI]; ok {
|
|
return value, nil
|
|
}
|
|
value, rewriteErr := rewrite(rawURI)
|
|
if rewriteErr != nil {
|
|
return "", rewriteErr
|
|
}
|
|
rewritten[rawURI] = value
|
|
return value, nil
|
|
}
|
|
|
|
seenAlternatives := make(map[*m3u8.Alternative]struct{})
|
|
for _, variant := range master.Variants {
|
|
if variant == nil {
|
|
continue
|
|
}
|
|
variant.URI, err = rewriteURI(variant.URI)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("rewrite HLS variant: %w", err)
|
|
}
|
|
for _, alternative := range variant.Alternatives {
|
|
if alternative == nil {
|
|
continue
|
|
}
|
|
if _, ok := seenAlternatives[alternative]; ok {
|
|
continue
|
|
}
|
|
seenAlternatives[alternative] = struct{}{}
|
|
alternative.URI, err = rewriteURI(alternative.URI)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("rewrite HLS alternative: %w", err)
|
|
}
|
|
}
|
|
}
|
|
return master.Encode(), nil
|
|
}
|
|
|
|
func Create(src *m3u8.MediaPlaylist, cdnUrl, serUrl, key, mds, authKey, keyVersion, source string) *bytes.Buffer {
|
|
p, e := m3u8.NewMediaPlaylist(src.WinSize(), src.Count())
|
|
if e != nil {
|
|
log.Error(fmt.Sprintf("Creating of media playlist failed: %s", e))
|
|
return nil
|
|
}
|
|
p.SetVersion(src.Version())
|
|
p.SeqNo = src.SeqNo
|
|
p.DiscontinuitySeq = src.DiscontinuitySeq
|
|
p.StartTime = src.StartTime
|
|
p.StartTimePrecise = src.StartTimePrecise
|
|
p.MediaType = src.MediaType
|
|
p.Iframe = src.Iframe
|
|
p.Args = src.Args
|
|
p.WV = src.WV
|
|
for _, customTag := range src.Custom {
|
|
p.SetCustomTag(customTag)
|
|
}
|
|
now := time.Now()
|
|
var activeMap *m3u8.Map
|
|
if src.Map != nil {
|
|
activeMap = src.Map
|
|
p.SetDefaultMap(
|
|
rewritePlaylistMediaURI(src.Map.URI, cdnUrl, authKey, keyVersion, mds, source, now),
|
|
src.Map.Limit,
|
|
src.Map.Offset,
|
|
)
|
|
}
|
|
activeKey := src.Key
|
|
for _, v := range src.Segments {
|
|
if v != nil {
|
|
currentMapURI := ""
|
|
if activeMap != nil {
|
|
currentMapURI = activeMap.URI
|
|
}
|
|
if v.Map != nil {
|
|
currentMapURI = v.Map.URI
|
|
}
|
|
tsurl := resolvePlaylistMediaURI(v.URI, currentMapURI)
|
|
tsurl = rewritePlaylistMediaURI(tsurl, cdnUrl, authKey, keyVersion, mds, source, now)
|
|
if err := p.Append(tsurl, v.Duration, v.Title); err != nil {
|
|
log.Error(fmt.Sprintf("Appending of media playlist failed: %s", err))
|
|
return nil
|
|
}
|
|
if v.Limit > 0 {
|
|
if err := p.SetRange(v.Limit, v.Offset); err != nil {
|
|
log.Error(fmt.Sprintf("Setting media byte range failed: %s", err))
|
|
return nil
|
|
}
|
|
}
|
|
if v.Discontinuity {
|
|
if err := p.SetDiscontinuity(); err != nil {
|
|
log.Error(fmt.Sprintf("Setting media discontinuity failed: %s", err))
|
|
return nil
|
|
}
|
|
}
|
|
if !v.ProgramDateTime.IsZero() {
|
|
if err := p.SetProgramDateTime(v.ProgramDateTime); err != nil {
|
|
log.Error(fmt.Sprintf("Setting media program date failed: %s", err))
|
|
return nil
|
|
}
|
|
}
|
|
if v.SCTE != nil {
|
|
if err := p.SetSCTE35(v.SCTE); err != nil {
|
|
log.Error(fmt.Sprintf("Setting media SCTE tag failed: %s", err))
|
|
return nil
|
|
}
|
|
}
|
|
for _, customTag := range v.Custom {
|
|
if err := p.SetCustomSegmentTag(customTag); err != nil {
|
|
log.Error(fmt.Sprintf("Setting media custom tag failed: %s", err))
|
|
return nil
|
|
}
|
|
}
|
|
if v.Map != nil && !playlistMapsEqual(v.Map, activeMap) {
|
|
if err := p.SetMap(
|
|
rewritePlaylistMediaURI(v.Map.URI, cdnUrl, authKey, keyVersion, mds, source, now),
|
|
v.Map.Limit,
|
|
v.Map.Offset,
|
|
); err != nil {
|
|
log.Error(fmt.Sprintf("Setting map of media playlist failed: %s", err))
|
|
return nil
|
|
}
|
|
activeMap = v.Map
|
|
}
|
|
if v.Key != nil && !playlistKeysEqual(v.Key, activeKey) {
|
|
if err := p.SetKey(
|
|
v.Key.Method,
|
|
playlistKeyURI(v.Key.URI, serUrl, mds),
|
|
playlistKeyIV(v.Key.IV, key),
|
|
v.Key.Keyformat,
|
|
v.Key.Keyformatversions,
|
|
); err != nil {
|
|
log.Error(fmt.Sprintf("Setting segment key failed: %s", err))
|
|
return nil
|
|
}
|
|
activeKey = v.Key
|
|
}
|
|
}
|
|
}
|
|
if src.Key != nil {
|
|
_ = p.SetDefaultKey(
|
|
src.Key.Method,
|
|
playlistKeyURI(src.Key.URI, serUrl, mds),
|
|
playlistKeyIV(src.Key.IV, key),
|
|
src.Key.Keyformat,
|
|
src.Key.Keyformatversions,
|
|
)
|
|
}
|
|
if src.TargetDuration > p.TargetDuration {
|
|
p.TargetDuration = src.TargetDuration
|
|
}
|
|
p.Close()
|
|
return p.Encode()
|
|
}
|
|
|
|
func playlistMapsEqual(a, b *m3u8.Map) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
return a.URI == b.URI && a.Limit == b.Limit && a.Offset == b.Offset
|
|
}
|
|
|
|
func playlistKeysEqual(a, b *m3u8.Key) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
return a.Method == b.Method &&
|
|
a.URI == b.URI &&
|
|
a.IV == b.IV &&
|
|
a.Keyformat == b.Keyformat &&
|
|
a.Keyformatversions == b.Keyformatversions
|
|
}
|
|
|
|
func playlistKeyURI(sourceKeyURI, serverURL, mds string) string {
|
|
if mds != constant.MediaSourcePMS {
|
|
return serverURL
|
|
}
|
|
if strings.Contains(sourceKeyURI, "/mt/enkeymt") {
|
|
return serverURL + "/pms/mt_sec"
|
|
}
|
|
return serverURL + "/pms/sec"
|
|
}
|
|
|
|
func playlistKeyIV(sourceIV, override string) string {
|
|
if override != "" {
|
|
return override
|
|
}
|
|
return sourceIV
|
|
}
|
|
|
|
// resolvePlaylistMediaURI uses an absolute EXT-X-MAP URI as the base for
|
|
// relative fMP4/CMAF segment paths.
|
|
func resolvePlaylistMediaURI(mediaURI, mapURI string) string {
|
|
if strings.TrimSpace(mediaURI) == "" || strings.TrimSpace(mapURI) == "" {
|
|
return mediaURI
|
|
}
|
|
parsedMediaURI, err := url.Parse(mediaURI)
|
|
if err == nil && parsedMediaURI.IsAbs() {
|
|
return mediaURI
|
|
}
|
|
parsedMapURI, err := url.Parse(mapURI)
|
|
if err != nil || !parsedMapURI.IsAbs() {
|
|
return mediaURI
|
|
}
|
|
parsedMapURI.RawQuery = ""
|
|
parsedMapURI.Fragment = ""
|
|
ref, err := url.Parse(mediaURI)
|
|
if err != nil {
|
|
return mediaURI
|
|
}
|
|
return parsedMapURI.ResolveReference(ref).String()
|
|
}
|
|
|
|
// rewritePlaylistMediaURI applies the same source, auth and CDN rewriting to
|
|
// regular media segments and EXT-X-MAP initialization segments.
|
|
func rewritePlaylistMediaURI(mediaURI, cdnUrl, authKey, keyVersion, mds, source string, now time.Time) string {
|
|
mediaURL := mediaURI
|
|
if mds == constant.MediaSourceLaoSiJi {
|
|
mediaURL = removeDomainPrefix(mediaURL)
|
|
mediaURL = filepath.Join("laosiji", mediaURL)
|
|
}
|
|
if len(authKey) > 0 {
|
|
uri := filepath.Join(filepath.Dir(source), mediaURL)
|
|
if mds == constant.MediaSourceLaoSiJi {
|
|
uri = "/" + mediaURL
|
|
}
|
|
urlAuth := generateUrlAuth(now, uri, authKey, keyVersion)
|
|
mediaURL = fmt.Sprintf("%s%s", mediaURL, urlAuth)
|
|
}
|
|
if cdnUrl != "" {
|
|
mediaURL = common.BindUrl(cdnUrl, mediaURL)
|
|
}
|
|
return mediaURL
|
|
}
|
|
|
|
func GetTsUrlFromReader(reader []byte, preUrl, cdnUrl string) []string {
|
|
p, listType, err := m3u8.DecodeFrom(bytes.NewReader(reader), false)
|
|
if err != nil {
|
|
log.Error("m3u8 decodeFrom error", log.E(err))
|
|
return []string{}
|
|
}
|
|
var tsUrl []string
|
|
switch listType {
|
|
case m3u8.MEDIA:
|
|
src := p.(*m3u8.MediaPlaylist)
|
|
tsUrl = make([]string, 0, len(src.Segments))
|
|
for _, v := range src.Segments {
|
|
if v != nil {
|
|
tsUrl = append(tsUrl, common.BindUrl(cdnUrl, preUrl, v.URI))
|
|
}
|
|
}
|
|
}
|
|
return tsUrl
|
|
}
|
|
|
|
func IsM3u8(reader []byte) bool {
|
|
_, _, err := m3u8.DecodeFrom(bytes.NewReader(reader), false)
|
|
return err == nil
|
|
}
|
|
|
|
const timeout = 5 * time.Second
|
|
|
|
// UploadSuccess 上传成功回调, 通知文件服务, 文件上传完成
|
|
func UploadSuccess(id string) (code stderr.Code) {
|
|
var params = map[string]interface{}{
|
|
"id": id,
|
|
}
|
|
c, cancle := context.WithTimeout(context.Background(), timeout)
|
|
defer cancle()
|
|
respBody := commod.Resp{}
|
|
//请求
|
|
httpStatus, err := httputil.DefaultClientPostJsonWithRespWithCtx(c, &respBody, webg.Conf.URL.FileInfoUrl, nil, params)
|
|
log.Info("http method UploadSuccess response code ==>", log.Any("httpStatus", httpStatus), log.Any("respCode", respBody.Code))
|
|
if err != nil {
|
|
log.Error("UploadSuccess POSTJsonWithJResp error", log.E(err))
|
|
return stderr.ErrConnectToFs
|
|
}
|
|
if respBody.Code != http.StatusOK {
|
|
log.Error("UploadSuccess status error", log.Any("respBody.Code", respBody.Code), log.E(err))
|
|
return stderr.ErrFsServerFile
|
|
}
|
|
return stderr.Success
|
|
}
|
|
|
|
// 媒体资源库选择
|
|
func GetMediaResouce(source string) string {
|
|
if strings.HasPrefix(source, "v1/") || strings.HasPrefix(source, "/v1/") ||
|
|
strings.HasPrefix(source, "v2/") || strings.HasPrefix(source, "/v2/") ||
|
|
strings.HasPrefix(source, "v3/") || strings.HasPrefix(source, "/v3/") {
|
|
return constant.MediaSourceJH1B
|
|
}
|
|
|
|
if strings.HasPrefix(source, constant.MediaSourcePMSPrefixPath) {
|
|
return constant.MediaSourcePMS
|
|
}
|
|
if strings.HasPrefix(source, constant.MediaSourceSPPrefixPath) {
|
|
return constant.MediaSourceSP
|
|
}
|
|
if strings.Contains(source, "laosiji") {
|
|
return constant.MediaSourceLaoSiJi
|
|
}
|
|
return constant.MediaSourceSP
|
|
}
|
|
|
|
// 生成鉴权url
|
|
func generateUrlAuth(now time.Time, path, authKey, keyVersion string) string {
|
|
timestamp := now.Unix()
|
|
// randId := 0
|
|
//signStr := fmt.Sprintf("%s%s%d", authKey, path, timestamp)
|
|
//md5Str := getMD5Sign(signStr)
|
|
//urlAuth := fmt.Sprintf("?t=%d&k=%s", timestamp, md5Str)
|
|
signStr := fmt.Sprintf("%s-%d-0-0-%s", path, timestamp, authKey)
|
|
md5Str := getMD5Sign(signStr)
|
|
// c={appid}(commod.KFK_APPID) 供 CDN 按应用区分统计/路由;v={keyVersion} 供 CDN 按版本选择校验密钥
|
|
urlAuth := fmt.Sprintf("?md=%d-0-0-%s&c=%d&v=%s", timestamp, md5Str, 209, 1)
|
|
return urlAuth
|
|
}
|
|
|
|
// getMD5Sign 得到签名
|
|
func getMD5Sign(buf string) string {
|
|
md5Ctx := md5.New()
|
|
md5Ctx.Write([]byte(buf))
|
|
cipherStr := md5Ctx.Sum(nil)
|
|
nsign := hex.EncodeToString(cipherStr)
|
|
return nsign
|
|
}
|
|
|
|
func replaceDomainAndPath(originalURL, newBase string) string {
|
|
// 解析原始 URL
|
|
parsedURL, err := url.Parse(originalURL)
|
|
if err != nil {
|
|
return originalURL
|
|
}
|
|
|
|
// 解析新基础 URL
|
|
newBaseURL, err := url.Parse(newBase)
|
|
if err != nil {
|
|
return originalURL
|
|
}
|
|
|
|
// 替换协议、主机和基础路径
|
|
parsedURL.Scheme = newBaseURL.Scheme
|
|
parsedURL.Host = newBaseURL.Host
|
|
|
|
// 构建新路径:/laosiji + 原始路径(去掉旧域名部分)
|
|
oldBasePath := ""
|
|
parsedURL.Path = path.Join(newBaseURL.Path, strings.TrimPrefix(parsedURL.Path, oldBasePath))
|
|
|
|
// 移除查询参数
|
|
parsedURL.RawQuery = ""
|
|
|
|
return parsedURL.String()
|
|
}
|
|
func removeDomainPrefix(originalURL string) string {
|
|
// 解析原始 URL
|
|
parsedURL, err := url.Parse(originalURL)
|
|
if err != nil {
|
|
return originalURL
|
|
}
|
|
oldBasePath := ""
|
|
return strings.TrimPrefix(parsedURL.Path, oldBasePath)
|
|
}
|