Files
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

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, commod.KFK_APPID, keyVersion)
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)
}