Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
350 lines
10 KiB
Go
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
|
|
}
|