Files
web_hgtv_server/app/service/videoser/m3u8.go
T
2026-10-24 11:22:21 +08:00

350 lines
10 KiB
Go

package videoser
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"videoweb/app/appg"
"videoweb/app/service/configser"
"videoweb/pub/common"
"videoweb/pub/common/enum/consts"
"videoweb/pub/common/enum/redisconst"
"videoweb/pub/common/ipaddr"
"videoweb/pub/common/log"
"videoweb/pub/common/m3u8"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/m3u8requestlogmod"
"videoweb/pub/redis"
"github.com/gin-gonic/gin"
)
const m3u8RateLimitFallback = H5M3U8FallbackPath
type fileHandler func(ctx *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error)
func Enkey(ctx *gin.Context) {
handleTicketEnkey(ctx, false)
}
// NewEnkey 返回短剧未完成水印时使用的新 M3U8 密钥。
func NewEnkey(ctx *gin.Context) {
handleTicketEnkey(ctx, true)
}
func handleTicketEnkey(ctx *gin.Context, useNewKey bool) {
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
idStr := c.Query("id")
tStr := c.Query("t")
ticket := c.Query("ticket")
if idStr == "" || tStr == "" || ticket == "" {
err = errors.New("unauthorized")
return
}
videoId, parseErr := strconv.ParseInt(idStr, 10, 64)
if parseErr != nil {
err = errors.New("invalid id")
return
}
tVal, parseErr := strconv.ParseInt(tStr, 10, 64)
if parseErr != nil {
err = errors.New("invalid t")
return
}
ip := ipaddr.ResolveClientIP(c)
ticketInfo, ticketOK := CheckH5M3U8TicketForEnkey(videoId, tVal, ip, ticket)
if !ticketOK || ticketInfo.UseNewKey != useNewKey {
log.Warn("Enkey: ticket invalid", log.Any("ip", ip), log.Any("videoId", videoId))
err = errors.New("ticket check failed")
return
}
var buffer *bytes.Buffer
if useNewKey {
buffer, err = m3u8.NewEnkey(c)
} else {
buffer, err = m3u8.Enkey(c)
}
if err != nil {
return
}
contentLength = int64(buffer.Len())
contentType = "application/octet-stream"
reader = buffer
extraHeaders = map[string]string{"Cache-Control": "private, no-store"}
return
}
handlerFile(ctx, fn)
}
func handlerFile(ctx *gin.Context, h fileHandler) {
contentLength, contentType, reader, extraHeaders, err := h(ctx)
if err != nil {
common.ServerJSON(ctx, srvdef.DownloadM3u8File, err)
return
}
ctx.DataFromReader(http.StatusOK, contentLength, contentType, reader, extraHeaders)
}
func GetM3U8Ex(ctx *gin.Context) {
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
start := time.Now()
m3u8Name := ctx.Param("key")
origM3u8Name := m3u8Name
status := "success"
var mediaID int64
var expireAt int64
var ticketOK bool
var isOverseas bool
var cdnDomain string
ip := ipaddr.ResolveClientIP(c)
isOverseas = configser.IsOverseasIP(ip)
defer func() {
if err != nil && status == "success" {
status = "error"
}
recordM3U8RequestLog(c, m3u8Name, mediaID, expireAt, ticketOK, isOverseas, cdnDomain, status, err, start)
}()
ticket := ctx.Query("ticket")
mediaID = parseInt64(ctx.Query("id"))
expireAt = parseInt64(ctx.Query("t"))
ticketInfo, ticketOK := CheckH5M3U8Ticket(
mediaID, expireAt, origM3u8Name, ip, ticket,
)
// if !ticketOK {
// m3u8Name = H5M3U8FallbackPath
// log.Warn("【GetM3U8Ex】H5播放票据无效",
// log.Any("mediaId", mediaID),
// log.Any("expireAt", expireAt),
// log.Any("ip", ip),
// log.Any("hasTicket", ticket != ""))
// }
countTowardDailyLimit := ticketOK && ticketInfo.Playable && !ticketInfo.Preview
if countTowardDailyLimit && appg.Conf.RunMod == string(consts.RunModeProd) {
userId := resolveM3U8RateLimitUserId(c, ticketInfo)
vipValid := isM3U8TicketVipValid(ticketInfo)
limit := redisconst.M3U8DayRateLimitMax
var cnt int64
var countErr error
if vipValid {
limit = redisconst.M3U8DayRateLimitVipMax
cnt, countErr = m3u8DailyMediaCountByUser(userId, mediaID)
} else {
cnt, countErr = m3u8DailyMediaCountByIP(ip, mediaID)
}
if countErr != nil {
log.Error("【GetM3U8Ex】统计正片获取次数失败",
log.Any("ip", ip),
log.Any("userId", userId),
log.Any("vipValid", vipValid),
log.E(countErr))
} else if cnt > limit {
// m3u8Name = m3u8RateLimitFallback
status = "rate_limited"
log.Warn("GetM3U8Ex daily rate limit, use fallback m3u8",
log.Any("ip", ip),
log.Any("userId", userId),
log.Any("vipValid", vipValid),
log.Any("limit", limit),
log.Any("remoteIP", c.RemoteIP()),
log.Any("clientIP", c.ClientIP()),
log.Any("xff", c.GetHeader("X-Forwarded-For")),
log.Any("cnt", cnt),
log.Any("mediaId", mediaID),
log.Any("orig", origM3u8Name),
log.Any("fallback", m3u8Name))
}
}
var req m3u8.Req
if err = ctx.BindQuery(&req); err != nil {
status = "bind_query_failed"
return
}
req.Name = m3u8Name
cdnDomain = configser.GetRandCDN()
originDomain := appg.Conf.OrigDomain
if isOverseas && len(configser.GetConfigInfo().MediaAppUsCdn) > 0 {
cdnDomain = configser.GetConfigInfo().MediaAppUsCdn
}
log.Info("cdn信息", log.Any("ip:", ip), log.Any("isOverseas", isOverseas), log.Any("cdnDomain", cdnDomain))
enkeyUri := ""
if ticketOK {
enkeyPath := "/enkey"
if ticketInfo.UseNewKey {
enkeyPath = "/newenkey"
}
enkeyUri = fmt.Sprintf("%s?id=%d&t=%d&ticket=%s", enkeyPath, mediaID, expireAt, url.QueryEscape(ticket))
}
var buff *bytes.Buffer
if ticketOK && ticketInfo.UseNewKey {
_, buff, err = m3u8.TransformExOriginalPath(req.Name, cdnDomain, originDomain, 2, appg.Conf.TsAuthKey, appg.Conf.LsjMediaConf.M3u8Yuan, appg.Conf.CdnAuthKey, enkeyUri)
} else {
_, buff, err = m3u8.TransformEx(req.Name, cdnDomain, originDomain, 2, appg.Conf.TsAuthKey, appg.Conf.LsjMediaConf.M3u8Yuan, appg.Conf.CdnAuthKey, enkeyUri)
}
if err != nil {
status = "transform_failed"
return
}
contentType = "application/vnd.apple.mpegurl"
reader = bytes.NewReader(buff.Bytes())
contentLength = int64(buff.Len())
extraHeaders = map[string]string{"Cache-Control": "private, no-store"}
return
}
handlerFile(ctx, fn)
}
func parseInt64(value string) int64 {
n, _ := strconv.ParseInt(value, 10, 64)
return n
}
func resolveM3U8RateLimitUserId(c *gin.Context, ticketInfo *H5M3U8Ticket) int64 {
if ticketInfo != nil && ticketInfo.UserId > 0 {
return ticketInfo.UserId
}
if c == nil {
return 0
}
return common.GetUserId(c)
}
// isM3U8TicketVipValid 用票据内签发时写入的会员过期时间判断,播放热路径不查库。
func isM3U8TicketVipValid(ticketInfo *H5M3U8Ticket) bool {
return ticketInfo != nil && ticketInfo.UserId > 0 && ticketInfo.VipExpireAt > time.Now().Unix()
}
func m3u8DailyMediaCountByIP(ip string, mediaID int64) (int64, error) {
return m3u8DailyPlayableCount(
redisconst.M3U8DayRateLimitIpKey(ip),
redisconst.M3U8DailyMediaIpKey(ip, mediaID),
)
}
func m3u8DailyMediaCountByUser(userId int64, mediaID int64) (int64, error) {
return m3u8DailyPlayableCount(
redisconst.M3U8DayRateLimitUserKey(userId),
redisconst.M3U8DailyMediaUserKey(userId, mediaID),
)
}
func m3u8DailyPlayableCount(countKey, firstMediaKey string) (int64, error) {
expire := redisconst.M3U8DayRateLimitExpire()
first, err := redis.Setnx_NewOK(firstMediaKey, 1, expire)
if err != nil {
return 0, err
}
if first {
return redis.Incr(countKey, expire)
}
value, err := redis.Get(countKey)
if err != nil || value == nil {
return 0, err
}
return strconv.ParseInt(*value, 10, 64)
}
func GetM3U8AuthParam(videoId int64, urlPath, ip, ua string, userId int64) string {
_ = ua
if signed := BuildH5M3U8LegacyURL(videoId, urlPath, ip, true, false, userId); signed != "" {
return signed
}
return urlPath
}
// GetPlayletM3U8AuthParam 为短剧剧集地址增加票据,并指定是否使用原片新密钥。
func GetPlayletM3U8AuthParam(chapterID int64, urlPath, ip string, useNewKey bool, userId int64) string {
if signed := BuildH5M3U8PlayletURL(chapterID, urlPath, ip, useNewKey, userId); signed != "" {
return signed
}
return urlPath
}
func LsjEnkey(ctx *gin.Context) {
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
buffer, err := m3u8.LsjEnkey(c)
if err != nil {
return
}
contentLength = int64(buffer.Len())
contentType = "application/octet-stream"
reader = buffer
extraHeaders = map[string]string{}
return
}
handlerFile(ctx, fn)
}
func recordM3U8RequestLog(ctx *gin.Context, m3u8Name string, videoId int64, t int64, ticketOK bool, isOverseas bool, cdnDomain string, status string, reqErr error, start time.Time) {
doc := &m3u8requestlogmod.M3U8RequestLog{
CreatedAt: time.Now(),
Host: ctx.Request.Host,
RequestURI: ctx.Request.RequestURI,
M3U8Path: m3u8Name,
RawQuery: ctx.Request.URL.RawQuery,
Query: cloneURLValues(ctx.Request.URL.Query()),
Headers: cloneHeader(ctx.Request.Header),
VideoId: videoId,
T: t,
Token: ctx.Query("ticket"),
ClientIP: ipaddr.ResolveClientIP(ctx),
RemoteAddr: ctx.Request.RemoteAddr,
XForwardedFor: ctx.GetHeader("X-Forwarded-For"),
XRealIP: ctx.GetHeader("X-Real-IP"),
CFConnectingIP: ctx.GetHeader("CF-Connecting-IP"),
UserAgent: ctx.Request.UserAgent(),
Referer: ctx.GetHeader("Referer"),
IsOverseas: isOverseas,
CDNDomain: cdnDomain,
TokenOK: ticketOK,
PathOK: true,
Status: status,
CostMs: time.Since(start).Milliseconds(),
}
if reqErr != nil {
doc.Error = reqErr.Error()
}
go func(data *m3u8requestlogmod.M3U8RequestLog) {
c, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if serr := m3u8requestlogmod.InsertOneM3U8RequestLog(c, data); serr != nil {
log.Warn("recordM3U8RequestLog insert failed", log.Any("status", data.Status), log.Any("path", data.M3U8Path))
}
}(doc)
}
func cloneHeader(header http.Header) map[string][]string {
return cloneStringValues(map[string][]string(header))
}
func cloneURLValues(values url.Values) map[string][]string {
return cloneStringValues(map[string][]string(values))
}
func cloneStringValues(values map[string][]string) map[string][]string {
result := make(map[string][]string, len(values))
for key, vals := range values {
copied := make([]string, len(vals))
copy(copied, vals)
result[key] = copied
}
return result
}