init: web_hgdjc_server

This commit is contained in:
2026-11-07 10:46:18 +08:00
commit b2ff6ac8ff
707 changed files with 147645 additions and 0 deletions
+60
View File
@@ -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
}
+255
View File
@@ -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
}
+349
View File
@@ -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
}
+45
View File
@@ -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
+15
View File
@@ -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,
})
}
+15
View File
@@ -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,
})
}
+15
View File
@@ -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,
})
}
+15
View File
@@ -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,
})
}
+158
View File
@@ -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()
}