Files
web_hghl_server/app/service/videoser/m3u8.go
T
2026-10-28 17:28:17 +08:00

389 lines
12 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/models/playletchaptermod"
"videoweb/pub/models/playletmod"
"videoweb/pub/redis"
"go.mongodb.org/mongo-driver/bson"
"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) {
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
// 已取消 /enkey 的 ticket 校验,直接返回加密 key
// 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 {
// log.Warn("Enkey: ticket invalid", log.Any("ip", ip), log.Any("videoId", videoId))
// err = errors.New("ticket check failed")
// return
// }
// if ticketInfo.OfficialPlayletID > 0 && !officialChapterAllowed(c, ticketInfo.OfficialPlayletID, videoId) {
// err = errors.New("本集暂未在官网开放")
// return
// }
buffer, bErr := m3u8.Enkey(c)
if bErr != nil {
err = bErr
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)
detector := appg.DetectorV6
if ipaddr.IsIPv4(ip) {
detector = appg.DetectorV4
}
if detector != nil {
isOverseas, _ = detector.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, _ := CheckH5M3U8TicketReason(
mediaID, expireAt, origM3u8Name, ip, ticket,
)
// if !ticketOK {
// m3u8Name = H5M3U8FallbackPath
// log.Warn("【GetM3U8Ex】H5播放票据无效",
// log.Any("reason", ticketReason),
// log.Any("mediaId", mediaID),
// log.Any("expireAt", expireAt),
// log.Any("ip", ip),
// log.Any("clientIP", c.ClientIP()),
// log.Any("remoteIP", c.RemoteIP()),
// log.Any("xff", c.GetHeader("X-Forwarded-For")),
// log.Any("cfIP", c.GetHeader("CF-Connecting-IP")),
// log.Any("path", origM3u8Name),
// log.Any("hasTicket", ticket != ""))
// }
if ticketOK && ticketInfo.OfficialPlayletID > 0 && !officialChapterAllowed(c, ticketInfo.OfficialPlayletID, mediaID) {
status = "official_closed"
err = errors.New("本集暂未在官网开放")
return
}
countTowardDailyLimit := ticketOK && ticketInfo.Playable && !ticketInfo.Preview
if countTowardDailyLimit && appg.Conf.RunMod == string(consts.RunModeProd) {
cnt, countErr := m3u8DailyMediaCountByIP(ip, mediaID)
if countErr != nil {
log.Error("【GetM3U8Ex】统计IP正片获取次数失败", log.Any("ip", ip), log.E(countErr))
} else if cnt > configser.GetM3U8DayRateLimitMax() {
// m3u8Name = m3u8RateLimitFallback
status = "rate_limited"
log.Warn("GetM3U8Ex daily rate limit, use fallback m3u8",
log.Any("ip", ip),
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 := ""
playOriginal := ticketOK && ticketInfo.UseNewKey && m3u8Name != H5M3U8FallbackPath
if ticketOK {
enkeyPath := "/enkey"
if playOriginal {
enkeyPath = "/newenkey"
}
enkeyUri = fmt.Sprintf("%s?id=%d&t=%d&ticket=%s", enkeyPath, mediaID, expireAt, url.QueryEscape(ticket))
}
var buff *bytes.Buffer
if playOriginal {
_, 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 m3u8DailyMediaCountByIP(ip string, mediaID int64) (int64, error) {
expire := redisconst.M3U8DayRateLimitExpire()
first, err := redis.Setnx_NewOK(redisconst.M3U8DailyMediaIpKey(ip, mediaID), 1, expire)
if err != nil {
return 0, err
}
countKey := redisconst.M3U8DayRateLimitIpKey(ip)
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) string {
_ = ua
if signed := BuildH5M3U8LegacyURL(videoId, urlPath, ip, true, false); signed != "" {
return signed
}
return urlPath
}
// GetPlayletM3U8AuthParam 为短剧剧集地址签发票据。useNewKey 表示当前地址是原片。
func GetPlayletM3U8AuthParam(chapterID int64, urlPath, ip string, useNewKey bool) string {
if signed := BuildH5M3U8PlayletURL(chapterID, urlPath, ip, useNewKey); signed != "" {
return signed
}
return urlPath
}
// GetM3U8OfficialParam 返回带官网开放范围校验的剧集播放地址
func GetM3U8OfficialParam(chapterID, playletID int64, urlPath, ip string, useNewKey bool) string {
return BuildH5M3U8OfficialURL(chapterID, playletID, urlPath, ip, useNewKey)
}
// NewEnkey 返回短剧原片解密密钥。
func NewEnkey(ctx *gin.Context) {
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 {
err = errors.New("ticket check failed")
return
}
if ticketInfo.OfficialPlayletID > 0 && !officialChapterAllowed(c, ticketInfo.OfficialPlayletID, videoId) {
err = errors.New("本集暂未在官网开放")
return
}
buffer, err := m3u8.NewEnkey(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)
}
// officialChapterAllowed 每次获取官网播放清单或密钥时重新读取开放范围
func officialChapterAllowed(ctx context.Context, playletID, chapterID int64) bool {
if playletID <= 0 || chapterID <= 0 {
return false
}
playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: playletID,
playletmod.FN.Playlet.Status: playletmod.StatusUp,
})
if err != nil || playlet == nil {
return false
}
chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{
playletchaptermod.FN.PlayletChapter.Id: chapterID,
playletchaptermod.FN.PlayletChapter.PlayletId: playletID,
})
if err != nil || chapter == nil {
return false
}
return playlet.IsOfficialEpisodeOpen(chapter.CurrentEpisode)
}
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 err1 := m3u8requestlogmod.InsertOneM3U8RequestLog(c, data); err1 != 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
}