init: web_hgdjc_server
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
)
|
||||
|
||||
func toVideoInfoData(videoInfo *videoinformationmod.VideoInformation, imgCdn string) proto.VideoInfoData {
|
||||
data := proto.VideoInfoToData(videoInfo, imgCdn)
|
||||
normalizeVideoInfoH265Paths(&data)
|
||||
configser.ApplyVideoH265Policy(&data)
|
||||
return data
|
||||
}
|
||||
|
||||
func applyVideoUrlAuth(resp *proto.VideoInfoData, videoId int64, ip, ua string, userId int64) {
|
||||
normalizeVideoInfoH265Paths(resp)
|
||||
configser.ApplyVideoH265Policy(resp)
|
||||
|
||||
if len(resp.Video.Url) > 0 {
|
||||
resp.Video.Url = GetM3U8AuthParam(videoId, resp.Video.Url, ip, ua, userId)
|
||||
}
|
||||
if len(resp.Video.H265Url) > 0 {
|
||||
resp.Video.H265Url = GetM3U8AuthParam(videoId, resp.Video.H265Url, ip, ua, userId)
|
||||
}
|
||||
for i, node := range resp.Nodes {
|
||||
for j, video := range node.Videos {
|
||||
if len(video.Url) > 0 {
|
||||
video.Url = GetM3U8AuthParam(videoId, video.Url, ip, ua, userId)
|
||||
}
|
||||
if len(video.H265Url) > 0 {
|
||||
video.H265Url = GetM3U8AuthParam(videoId, video.H265Url, ip, ua, userId)
|
||||
}
|
||||
node.Videos[j] = video
|
||||
}
|
||||
resp.Nodes[i] = node
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeVideoInfoH265Paths(data *proto.VideoInfoData) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
data.Video.H265Url = normalizeLaosijiHEVCPath(data.Video.H265Url)
|
||||
for i := range data.Nodes {
|
||||
for j := range data.Nodes[i].Videos {
|
||||
data.Nodes[i].Videos[j].H265Url = normalizeLaosijiHEVCPath(data.Nodes[i].Videos[j].H265Url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLaosijiHEVCPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
path = strings.TrimPrefix(path, "laosiji/m3m/")
|
||||
if path != "" && !strings.HasPrefix(path, "laosiji/") {
|
||||
return "laosiji/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/pub/common/crypt"
|
||||
"videoweb/pub/common/enum/redisconst"
|
||||
"videoweb/pub/common/ipaddr"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/models/usermod"
|
||||
"videoweb/pub/redis"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
const (
|
||||
h5M3U8TicketTTLSeconds = int64(600)
|
||||
h5M3U8TicketKeyVersion = "v1"
|
||||
h5M3U8TicketBindIP = true
|
||||
h5M3U8RoutePrefix = "/h5/m3u8/"
|
||||
hardcodedH5M3U8TicketKey = "3d058714838683a97c04d7b6ab609fc196f29e55df36197341d87325dc63154a"
|
||||
H5M3U8FallbackPath = "/v3/av/w0/w6/db/7j/029e2f2df53d4741ad39b471e2ad1806.m3u8"
|
||||
)
|
||||
|
||||
// H5M3U8Ticket 记录业务接口已确认的正片/预览权限,供 m3u8 接口可信计数。
|
||||
type H5M3U8Ticket struct {
|
||||
MediaID int64 `json:"mediaId"`
|
||||
Path string `json:"path"`
|
||||
ExpireAt int64 `json:"expireAt"`
|
||||
IPHash string `json:"ipHash,omitempty"`
|
||||
UserId int64 `json:"userId,omitempty"` // 签发时登录用户,供会员日限识别
|
||||
VipExpireAt int64 `json:"vipExpireAt,omitempty"` // 签发时会员过期时间(unix),播放侧据此判断会员日限,避免查库
|
||||
Playable bool `json:"playable"`
|
||||
Preview bool `json:"preview"`
|
||||
UseNewKey bool `json:"useNewKey,omitempty"`
|
||||
KeyVersion string `json:"keyVersion,omitempty"`
|
||||
}
|
||||
|
||||
func h5M3U8TicketKey() string {
|
||||
return hardcodedH5M3U8TicketKey
|
||||
}
|
||||
|
||||
// BuildH5M3U8LegacyURL 返回不带路由前缀的相对票据地址,兼容 H5 自行拼接 /h5/m3u8/。
|
||||
func BuildH5M3U8LegacyURL(mediaID int64, path, ip string, playable, preview bool, userId int64) string {
|
||||
return buildH5M3U8LegacyURL(mediaID, path, ip, playable, preview, false, userId)
|
||||
}
|
||||
|
||||
// BuildH5M3U8PlayletURL 为短剧播放地址生成票据,并在票据中记录原片密钥类型。
|
||||
func BuildH5M3U8PlayletURL(mediaID int64, path, ip string, useNewKey bool, userId int64) string {
|
||||
return buildH5M3U8LegacyURL(mediaID, path, ip, true, false, useNewKey, userId)
|
||||
}
|
||||
|
||||
func buildH5M3U8LegacyURL(mediaID int64, path, ip string, playable, preview, useNewKey bool, userId int64) string {
|
||||
key := h5M3U8TicketKey()
|
||||
if key == "" || mediaID <= 0 || strings.TrimSpace(ip) == "" {
|
||||
return ""
|
||||
}
|
||||
normalizedPath, ok := normalizeH5M3U8Path(path)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
expireAt := time.Now().Add(time.Duration(h5M3U8TicketTTLSeconds) * time.Second).Unix()
|
||||
ticket := H5M3U8Ticket{
|
||||
MediaID: mediaID,
|
||||
Path: normalizedPath,
|
||||
ExpireAt: expireAt,
|
||||
UserId: userId,
|
||||
VipExpireAt: lookupUserVipExpireUnix(userId),
|
||||
Playable: playable,
|
||||
Preview: preview,
|
||||
UseNewKey: useNewKey,
|
||||
KeyVersion: h5M3U8TicketKeyVersion,
|
||||
}
|
||||
if h5M3U8TicketBindIP {
|
||||
ticket.IPHash = h5M3U8Hash(ip)
|
||||
}
|
||||
encodedTicket, err := encodeH5M3U8Ticket(ticket, key)
|
||||
if err != nil {
|
||||
log.Error("【BuildH5M3U8LegacyURL】生成H5播放票据失败", log.E(err))
|
||||
return ""
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("id", strconv.FormatInt(mediaID, 10))
|
||||
query.Set("t", strconv.FormatInt(expireAt, 10))
|
||||
query.Set("ticket", encodedTicket)
|
||||
return normalizedPath + "?" + query.Encode()
|
||||
}
|
||||
|
||||
// CheckH5M3U8Ticket 校验 IP、视频、路径、有效期,防止伪造正片计数语义。
|
||||
func CheckH5M3U8Ticket(mediaID, expireAt int64, path, ip, ticket string) (*H5M3U8Ticket, bool) {
|
||||
data, ok := decodeAndValidateH5M3U8Ticket(mediaID, expireAt, ip, ticket)
|
||||
if !ok || data == nil {
|
||||
return nil, false
|
||||
}
|
||||
normalizedPath, pathOK := normalizeH5M3U8Path(path)
|
||||
if !pathOK {
|
||||
return nil, false
|
||||
}
|
||||
if payloadPath, valid := normalizeH5M3U8Path(data.Path); !valid || payloadPath != normalizedPath {
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
// CheckH5M3U8TicketForEnkey 校验 /enkey 请求,不校验 m3u8 路径。
|
||||
func CheckH5M3U8TicketForEnkey(mediaID, expireAt int64, ip, ticket string) (*H5M3U8Ticket, bool) {
|
||||
return decodeAndValidateH5M3U8Ticket(mediaID, expireAt, ip, ticket)
|
||||
}
|
||||
|
||||
func decodeAndValidateH5M3U8Ticket(mediaID, expireAt int64, ip, ticket string) (*H5M3U8Ticket, bool) {
|
||||
key := h5M3U8TicketKey()
|
||||
if key == "" || mediaID <= 0 || expireAt <= 0 || strings.TrimSpace(ticket) == "" {
|
||||
return nil, false
|
||||
}
|
||||
data, err := decodeH5M3U8Ticket(ticket, key)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if data.MediaID != mediaID || data.ExpireAt != expireAt {
|
||||
return nil, false
|
||||
}
|
||||
if data.KeyVersion != "" && data.KeyVersion != h5M3U8TicketKeyVersion {
|
||||
return nil, false
|
||||
}
|
||||
if time.Now().Unix() > data.ExpireAt {
|
||||
return nil, false
|
||||
}
|
||||
if h5M3U8TicketBindIP && data.IPHash != h5M3U8Hash(ip) {
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func H5M3U8RequestIP(ctx context.Context) string {
|
||||
if c, ok := ctx.(*gin.Context); ok {
|
||||
return strings.TrimSpace(ipaddr.ResolveClientIP(c))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeH5M3U8TicketParam(ticket string) string {
|
||||
ticket = strings.TrimSpace(ticket)
|
||||
if idx := strings.Index(ticket, "?"); idx >= 0 {
|
||||
ticket = ticket[:idx]
|
||||
}
|
||||
return ticket
|
||||
}
|
||||
|
||||
func normalizeH5M3U8Path(path string) (string, bool) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", false
|
||||
}
|
||||
lowerPath := strings.ToLower(path)
|
||||
if strings.HasPrefix(lowerPath, "http://") || strings.HasPrefix(lowerPath, "https://") {
|
||||
return "", false
|
||||
}
|
||||
if idx := strings.Index(path, "?"); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
path = strings.TrimLeft(path, "/")
|
||||
path = strings.TrimPrefix(path, strings.TrimLeft(h5M3U8RoutePrefix, "/"))
|
||||
lowerPath = strings.ToLower(path)
|
||||
if path == "" || strings.Contains(path, `\`) || strings.Contains(path, "..") || strings.Contains(lowerPath, "%2e") {
|
||||
return "", false
|
||||
}
|
||||
if !strings.HasSuffix(lowerPath, ".m3u8") {
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
func encodeH5M3U8Ticket(ticket H5M3U8Ticket, key string) (string, error) {
|
||||
data, err := json.Marshal(ticket)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encrypted, err := crypt.CoreAesEncryptEx(data, 12, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
func decodeH5M3U8Ticket(ticket, key string) (*H5M3U8Ticket, error) {
|
||||
ticket = normalizeH5M3U8TicketParam(ticket)
|
||||
encrypted, err := base64.URLEncoding.DecodeString(ticket)
|
||||
if err != nil {
|
||||
encrypted, err = base64.RawURLEncoding.DecodeString(ticket)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decrypted, err := safeCoreAesDecryptEx(encrypted, 12, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data H5M3U8Ticket
|
||||
if err = json.Unmarshal(decrypted, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func safeCoreAesDecryptEx(encrypted []byte, nonceLen int, key string) (decrypted []byte, err error) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("decrypt h5 m3u8 ticket panic: %v", recovered)
|
||||
}
|
||||
}()
|
||||
return crypt.CoreAesDecryptEx(encrypted, nonceLen, key)
|
||||
}
|
||||
|
||||
func h5M3U8Hash(value string) string {
|
||||
hash := sha256.Sum256([]byte(strings.TrimSpace(value)))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// lookupUserVipExpireUnix 取用户会员过期 unix 时间;优先 Redis 短缓存,未登录返回 0。
|
||||
// 仅在签发票据时调用,避免 /h5/m3u8 热路径查库。
|
||||
func lookupUserVipExpireUnix(userId int64) int64 {
|
||||
if userId <= 0 {
|
||||
return 0
|
||||
}
|
||||
cacheKey := redisconst.M3U8UserVipExpireKey(userId)
|
||||
if cached, err := redis.Get(cacheKey); err == nil && cached != nil {
|
||||
if n, parseErr := strconv.ParseInt(*cached, 10, 64); parseErr == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
user, serr := usermod.FindOneUser(ctx, bson.M{
|
||||
usermod.FN.User.Id: userId,
|
||||
}, options.FindOne().SetProjection(bson.D{
|
||||
{Key: usermod.FN.User.Id, Value: 1},
|
||||
{Key: usermod.FN.User.CurrentVipExpire, Value: 1},
|
||||
}))
|
||||
vipExpireAt := int64(0)
|
||||
if serr == nil && user != nil && !user.CurrentVipExpire.IsZero() {
|
||||
vipExpireAt = user.CurrentVipExpire.Unix()
|
||||
}
|
||||
_ = redis.Set(cacheKey, strconv.FormatInt(vipExpireAt, 10), redisconst.M3U8UserVipExpireCache)
|
||||
return vipExpireAt
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/models/videotopicmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
func resolveVideoID(ctx context.Context, id int64, slug string, required bool) (int64, *srvdef.SrvErr) {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug != "" {
|
||||
item, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Slug: slug,
|
||||
}, options.FindOne().SetProjection(bson.D{
|
||||
{Key: videoinformationmod.FN.VideoInformation.Id, Value: 1},
|
||||
}))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.Id, nil
|
||||
}
|
||||
if required && id <= 0 {
|
||||
return 0, srvdef.New(srvdef.ErrParamError, "视频ID不能为空", "视频ID不能为空")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func resolveVideoTopicID(ctx context.Context, id int64, slug string) (int64, *srvdef.SrvErr) {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug == "" {
|
||||
return id, nil
|
||||
}
|
||||
item, err := videotopicmod.FindOneVideoTopicModel(ctx, bson.M{
|
||||
videotopicmod.FN.VideoTopicModel.Slug: slug,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.Id, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/videocategorymod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
func GetVideoCategoryById(ctx context.Context, id int64) (*videocategorymod.VideoCategory, *srvdef.SrvErr) {
|
||||
return videocategorymod.FindOneVideoCategory(ctx, bson.M{
|
||||
videocategorymod.FN.VideoCategory.Id: id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/videocreatermod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
func GetVideoCreatorById(ctx context.Context, id int64) (*videocreatermod.VideoCreaterInfo, *srvdef.SrvErr) {
|
||||
return videocreatermod.FindOneVideoCreaterInfo(ctx, bson.M{
|
||||
videocreatermod.FN.VideoCreaterInfo.Id: id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/videotagmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
func GetVideoTagById(ctx context.Context, id int64) (*videotagmod.VideoTagInfo, *srvdef.SrvErr) {
|
||||
return videotagmod.FindOneVideoTagInfo(ctx, bson.M{
|
||||
videotagmod.FN.VideoTagInfo.Id: id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/videotopicmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
func GetVideoTopicById(ctx context.Context, id int64) (*videotopicmod.VideoTopicModel, *srvdef.SrvErr) {
|
||||
return videotopicmod.FindOneVideoTopicModel(ctx, bson.M{
|
||||
videotopicmod.FN.VideoTopicModel.Id: id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
http2 "net/http"
|
||||
"strings"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/pub/common"
|
||||
"videoweb/pub/common/enum/redisconst"
|
||||
"videoweb/pub/common/errors"
|
||||
"videoweb/pub/common/http"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/m3u8"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/common/utils"
|
||||
"videoweb/pub/redis"
|
||||
"videoweb/pub/storage/proxy"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetVTTEx(ctx *gin.Context) {
|
||||
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
|
||||
vttName := ctx.Param("key")
|
||||
var req m3u8.Req
|
||||
if err = ctx.BindQuery(&req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req.Name = vttName
|
||||
cfg := configser.GetConfigInfo()
|
||||
cdnDomain := cfg.ImgCdn
|
||||
originDomain := appg.Conf.OrigDomain
|
||||
|
||||
buff, err := TransformVttEx(req.Name, cdnDomain, originDomain)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
contentType = "application/vnd.apple.mpegurl"
|
||||
reader = bytes.NewReader(buff)
|
||||
contentLength = int64(len(buff))
|
||||
extraHeaders = map[string]string{}
|
||||
return
|
||||
}
|
||||
contentLength, contentType, reader, extraHeaders, err := fn(ctx)
|
||||
if err != nil {
|
||||
common.ServerJSON(ctx, srvdef.DownloadM3u8File, err)
|
||||
return
|
||||
}
|
||||
ctx.DataFromReader(http2.StatusOK, contentLength, contentType, reader, extraHeaders)
|
||||
}
|
||||
|
||||
func TransformVttEx(name, cdnDomain, originDomain string) (buff []byte, err error) {
|
||||
if name == "" {
|
||||
err = errors.MakeWithMsg(errors.M3u8FileName, "m3u8 file name is empty")
|
||||
return
|
||||
}
|
||||
if cdnDomain == "" || originDomain == "" {
|
||||
err = errors.MakeWithMsg(errors.DomainErr, "cdnDomain or origDomain is empty")
|
||||
return
|
||||
}
|
||||
// 处理媒资库 桶1(v1) 桶2(v2) 桶3(v3)
|
||||
mediaVerson := proxy.AppendsMediaS3Version(name)
|
||||
if len(mediaVerson) > 0 {
|
||||
name = mediaVerson + name
|
||||
}
|
||||
|
||||
// 原始vtt
|
||||
srcReader, err := readFromRedis(name, originDomain)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(srcReader)
|
||||
if err != nil {
|
||||
return nil, errors.MakeWithMsg(errors.M3u8Read, "read vtt reader fail: "+err.Error())
|
||||
}
|
||||
// 替换为cdn路径
|
||||
newData := rewriteVttWithCDN(raw, cdnDomain)
|
||||
reader := bytes.NewReader(newData)
|
||||
buff, err = io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, errors.MakeWithMsg(errors.M3u8Read, "read vtt fail: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 从redis缓存中读取
|
||||
func readFromRedis(name, domain string) (reader io.Reader, err error) {
|
||||
vttKey := redisconst.MediaM3U8Key(name)
|
||||
data, err := redis.GetBytes(vttKey)
|
||||
if err == nil && len(data) > 0 {
|
||||
reader = bytes.NewReader(data)
|
||||
return
|
||||
}
|
||||
requestUrl := utils.JoinUrlPath(domain, name)
|
||||
resp, err := http.GetLargeFile(requestUrl, nil)
|
||||
if err != nil {
|
||||
fmt.Println("requestUrl111", requestUrl)
|
||||
return
|
||||
}
|
||||
fmt.Println("requestUrl", requestUrl, resp.StatusCode)
|
||||
if resp.StatusCode != 200 {
|
||||
msg := fmt.Sprintf("read vtt fail, url: %s, status code %d", requestUrl, resp.StatusCode)
|
||||
err = errors.MakeWithMsg(errors.M3u8Read, msg)
|
||||
return
|
||||
}
|
||||
if err := redis.Set(vttKey, resp.Data,
|
||||
redisconst.MediaM3U8CacheExpire); err != nil {
|
||||
log.Info("vtt:readFromRedis redis set err", log.E(err))
|
||||
}
|
||||
reader = bytes.NewReader(resp.Data)
|
||||
return
|
||||
}
|
||||
|
||||
// rewriteVttWithCDN 将 vtt 内 "path#xywh=..." 的 path 改为 cdnDomain/path
|
||||
func rewriteVttWithCDN(vttData []byte, cdnDomain string) []byte {
|
||||
cdnDomain = strings.TrimSpace(cdnDomain)
|
||||
cdnDomain = strings.TrimRight(cdnDomain, "/")
|
||||
|
||||
var out bytes.Buffer
|
||||
scanner := bufio.NewScanner(bytes.NewReader(vttData))
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trim := strings.TrimSpace(line)
|
||||
|
||||
// 只处理缩略图行:xxx#xywh=...
|
||||
if strings.Contains(trim, "#xywh=") {
|
||||
parts := strings.SplitN(trim, "#xywh=", 2)
|
||||
if len(parts) == 2 {
|
||||
p := strings.TrimSpace(parts[0]) // 原路径
|
||||
xywh := parts[1]
|
||||
|
||||
// 已经是绝对URL则不改
|
||||
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
|
||||
out.WriteString(p + "#xywh=" + xywh + "\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理相对路径,确保只有一个 /
|
||||
p = strings.TrimLeft(p, "/")
|
||||
newPath := cdnDomain + "/" + p
|
||||
out.WriteString(newPath + "#xywh=" + xywh + "\n")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
// scanner.Err()
|
||||
return out.Bytes()
|
||||
}
|
||||
Reference in New Issue
Block a user