Files
web_hgtv_server/app/service/videoser/video.go
T
rootandClaude Opus 5.5 7974a42301 Initial commit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:07:42 +08:00

485 lines
16 KiB
Go

package videoser
import (
"context"
"regexp"
"strconv"
"strings"
"videoweb/app/api/proto"
"videoweb/app/service/configser"
"videoweb/pub/common/log"
"videoweb/pub/common/netutil"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/richtextcontentmod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/models/videotagmod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
videoListDefaultPageSize int64 = 10
videoRecommendLimit int64 = 20
videoMaxPageSize int64 = 100
// videoH5DetailPath 视频详情Web/H5页面路径,供SEO canonical使用
videoH5DetailPath = "/lieyan/av/detail"
)
var (
// videoListProjection 只读取列表需要展示的字段,降低MongoDB IO和网络传输
videoListProjection = bson.D{
{Key: videoinformationmod.FN.VideoInformation.Id, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Title, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.CreatorId, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.CreatorName, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.CoverImg, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Keyworld, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.VideoTime, Value: 1},
}
// videoListSort 按上架时间和ID倒序,保证普通分页排序稳定
videoListSort = bson.D{
{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1},
{Key: videoinformationmod.FN.VideoInformation.Id, Value: -1},
}
videoListHint = bson.D{
{Key: videoinformationmod.FN.VideoInformation.Status, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1},
{Key: videoinformationmod.FN.VideoInformation.Id, Value: -1},
}
videoRecommendHint = bson.D{
{Key: videoinformationmod.FN.VideoInformation.Status, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1},
{Key: videoinformationmod.FN.VideoInformation.Id, Value: -1},
}
// videoInfoProjection 只读取视频详情接口需要展示和签名的字段
videoInfoProjection = bson.D{
{Key: videoinformationmod.FN.VideoInformation.Id, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Title, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.CoverImg, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Keyworld, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Desc, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.TaskId, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.FileId, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.Url, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.H265Url, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.Cover, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.Height, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.Width, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.VideoTime, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.PreMp4FileNameFs, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.UnencryptedPreMp4, Value: 1},
}
)
type CategoryDataStruct struct {
VideoInfoData []proto.VideoInfoData
CategoryData proto.VideoCategoryInfo
VideoCount int64
}
type PublishDataStruct struct {
VideoInfoData []proto.VideoInfoData
CreaterData proto.VideoCreaterInfo
VideoCount int64
}
// VideoList 按上架时间倒序普通分页查询已上架视频
func VideoList(ctx context.Context, req *proto.VideoReq) (*proto.VideoResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
pageNum, pageSize := normalizeVideoPage(req.PageNum, req.PageSize, videoListDefaultPageSize)
filter := bson.M{
videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
}
totalCount, err := videoinformationmod.CountVideoInformations(ctx, filter, options.Count().SetHint(videoListHint))
if err != nil {
return nil, err
}
if totalCount == 0 {
return emptyVideoPage(pageNum, pageSize), nil
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
resp := emptyVideoPage(pageNum, pageSize)
resp.TotalCount = totalCount
return resp, nil
}
list, err := videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find().
SetProjection(videoListProjection).
SetSort(videoListSort).
SetHint(videoListHint).
SetSkip(skip).
SetLimit(pageSize))
if err != nil {
return nil, err
}
items := make([]*proto.VideoItemResp, 0, len(list))
imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx))
for _, item := range list {
items = append(items, videoToListResp(item, imgCdn))
}
return &proto.VideoResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: items,
}, nil
}
// VideoInfo 查询视频详情
func VideoInfo(ctx context.Context, req *proto.VideoInfoReq, userId int64, ip, ua string) (*proto.VideoInfoResp, *srvdef.SrvErr) {
if req == nil || req.Id <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
filter := bson.M{
videoinformationmod.FN.VideoInformation.Id: req.Id,
videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
}
item, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, filter, options.FindOne().
SetProjection(videoInfoProjection))
if err != nil {
return nil, err
}
go IncWatchTimes(context.Background(), item.Id)
imgCdn := configser.GetAppImageCdn(ip)
resp := videoToInfoResp(item, imgCdn, ip, ua, true, userId)
recommendVideos, err := queryRecommendVideos(ctx, item, imgCdn)
if err != nil {
return nil, err
}
resp.RecommendVideos = recommendVideos
resp.Advitise = configser.GetAdvitise()
resp.SeoInfo = buildVideoDetailSeoInfo(item)
return resp, nil
}
// queryRecommendVideos 按当前视频标签查询相关推荐
func queryRecommendVideos(ctx context.Context, current *videoinformationmod.VideoInformation, imgCdn string) ([]*proto.VideoItemResp, *srvdef.SrvErr) {
if current == nil || len(current.Tags) == 0 {
return make([]*proto.VideoItemResp, 0), nil
}
filter := bson.M{
videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
videoinformationmod.FN.VideoInformation.Tags: bson.M{"$in": current.Tags},
videoinformationmod.FN.VideoInformation.Id: bson.M{"$ne": current.Id},
}
list, err := videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find().
SetProjection(videoListProjection).
SetSort(videoListSort).
SetHint(videoRecommendHint).
SetLimit(videoRecommendLimit))
if err != nil {
return nil, err
}
items := make([]*proto.VideoItemResp, 0, len(list))
for _, item := range list {
items = append(items, videoToListResp(item, imgCdn))
}
return items, nil
}
// normalizeVideoPage 规范化视频普通分页参数,限制单次查询量保护接口和数据库
func normalizeVideoPage(pageNum, pageSize, defaultPageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = defaultPageSize
}
if pageSize > videoMaxPageSize {
pageSize = videoMaxPageSize
}
return pageNum, pageSize
}
func emptyVideoPage(pageNum, pageSize int64) *proto.VideoResp {
return &proto.VideoResp{
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]*proto.VideoItemResp, 0),
}
}
// videoToListResp 将视频模型转换为列表项
func videoToListResp(item *videoinformationmod.VideoInformation, imgCdn string) *proto.VideoItemResp {
return &proto.VideoItemResp{
Id: item.Id,
Title: item.Title,
AddedTime: item.AddedTime,
CreatorId: item.CreatorId,
CreatorName: item.CreatorName,
CoverImg: netutil.MergeURL(imgCdn, item.CoverImg),
Keyworld: item.Keyworld,
Tags: item.Tags,
WatchTimes: item.Real.WatchTimes + item.Fake.WatchTimes,
VideoTime: item.Video.VideoTime,
}
}
// videoToInfoResp 将视频模型转换为详情响应,并给视频播放地址增加鉴权参数
func videoToInfoResp(item *videoinformationmod.VideoInformation, imgCdn, ip, ua string, includeCurrentWatch bool, userId int64) *proto.VideoInfoResp {
video := proto.VideoData{
TaskId: item.Video.TaskId,
FileId: item.Video.FileId,
Url: item.Video.Url,
Cover: netutil.MergeURL(imgCdn, item.Video.Cover),
Height: item.Video.Height,
Width: item.Video.Width,
VideoTime: item.Video.VideoTime,
PreMp4FileNameFs: item.Video.PreMp4FileNameFs,
UnencryptedPreMp4: item.Video.UnencryptedPreMp4,
}
if video.Url != "" {
video.Url = GetM3U8AuthParam(item.Id, video.Url, ip, ua, userId)
}
watchTimes := item.Real.WatchTimes + item.Fake.WatchTimes
if includeCurrentWatch {
watchTimes++
}
resp := &proto.VideoInfoResp{
Id: item.Id,
Title: item.Title,
CoverImg: netutil.MergeURL(imgCdn, item.CoverImg),
Keyworld: item.Keyworld,
Tags: item.Tags,
TagsName: make([]string, 0, len(item.Tags)),
WatchTimes: watchTimes,
Video: video,
}
for _, tagId := range item.Tags {
tagInfo := configser.GetTag(tagId)
if tagInfo != nil {
resp.TagsName = append(resp.TagsName, tagInfo.Name)
}
}
return resp
}
// buildVideoDetailSeoInfo 组装视频详情页SEO信息
//
// canonical 统一指向Web/H5真实详情页:/lieyan/av/detail?id=视频ID
func buildVideoDetailSeoInfo(item *videoinformationmod.VideoInformation) *proto.SeoInfo {
seo := configser.GetSiteSeoInfo()
if item == nil {
return &seo
}
if title := strings.TrimSpace(item.Title); title != "" {
seo.Title = title
}
if desc := truncateVideoSEODescription(strings.TrimSpace(item.Desc), 120); desc != "" {
seo.Description = desc
}
seo.Keywords = buildVideoSEOKeywords(item, seo.Keywords)
seo.CanonicalURL = buildVideoDetailCanonicalURL(seo.CanonicalURL, item.Id)
return &seo
}
// buildVideoDetailCanonicalURL 拼接视频详情页标准URL
func buildVideoDetailCanonicalURL(baseURL string, id int64) string {
pagePath := videoH5DetailPath + "?id=" + strconv.FormatInt(id, 10)
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return pagePath
}
return baseURL + pagePath
}
// buildVideoSEOKeywords 合并视频关键词和全站关键词,并去重
func buildVideoSEOKeywords(item *videoinformationmod.VideoInformation, siteKeywords []string) []string {
seen := make(map[string]struct{}, len(item.Keyworld)+len(siteKeywords)+1)
keywords := make([]string, 0, len(item.Keyworld)+len(siteKeywords)+1)
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
keywords = append(keywords, value)
}
add(item.Title)
for _, keyword := range item.Keyworld {
add(keyword)
}
for _, keyword := range siteKeywords {
add(keyword)
}
return keywords
}
// truncateVideoSEODescription 限制meta description长度,避免详情长文本直接写进SEO描述
func truncateVideoSEODescription(desc string, maxRunes int) string {
if maxRunes <= 0 || desc == "" {
return ""
}
runes := []rune(desc)
if len(runes) <= maxRunes {
return desc
}
return string(runes[:maxRunes])
}
func VideoDetails(ctx context.Context, videoId int64, userId int64, ip, ua string) (resp proto.VideoInfoData, serr *srvdef.SrvErr) {
imgCdn := configser.GetAppImageCdn(ip)
videoFilter := bson.M{
videoinformationmod.FN.VideoInformation.Id: videoId,
videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
}
videoInfo, serr := videoinformationmod.FindOneVideoInformation(ctx, videoFilter)
if serr != nil {
log.Error("VideoDetails", log.Any("videoFilter", videoFilter), log.E(serr))
return
}
// 查询分类
resp = toVideoInfoData(videoInfo, imgCdn)
for _, tagId := range videoInfo.Tags {
tagInfo := configser.GetTag(tagId)
if tagInfo != nil {
resp.Tags = append(resp.Tags, proto.TagsInfoData{
Id: tagInfo.Id,
Name: tagInfo.Name,
})
}
}
if resp.WatchTimes > 0 {
resp.WatchTimes = resp.WatchTimes * 100
} else {
resp.WatchTimes = utils.GetRandom(2000, 10000)
}
if resp.Content != "" {
re := regexp.MustCompile(`data-src="([^"]+?\.m3u8)"`)
// 查找所有匹配项
matches := re.FindAllStringSubmatch(resp.Content, -1)
for _, match := range matches {
newUrl := GetM3U8AuthParam(videoId, match[1], ip, ua, userId)
resp.Content = strings.ReplaceAll(resp.Content, match[1], newUrl)
}
}
if len(resp.Video.Url) > 0 || len(resp.Nodes) > 0 {
applyVideoUrlAuth(&resp, videoId, ip, ua, userId)
}
go func() {
_ = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: videoId,
}, bson.M{"$inc": bson.M{videoinformationmod.FN.VideoInformation.Real.ShowTimes: 1}})
if len(videoInfo.Tags) > 0 {
tagIds := videoInfo.Tags
if len(tagIds) > 10 {
tagIds = tagIds[:10]
}
_ = videotagmod.UpdateManyVideoTagInfo(ctx, bson.M{
videotagmod.FN.VideoTagInfo.Id: bson.M{"$in": tagIds},
}, bson.M{"$inc": bson.M{
videotagmod.FN.VideoTagInfo.WatchTimes: 1,
}})
}
}()
return resp, nil
}
func RichtextData(ctx context.Context, richtextId int64) (proto.RichTextInfo, *srvdef.SrvErr) {
//cacheKey := fmt.Sprintf("rich_text_data_%d", richtextId)
var resp *proto.RichTextInfo
//if cacheData, exists := Cache.Get(cacheKey); exists {
// if item, ok := cacheData.(proto.RichTextInfo); ok {
// resp = &item
// }
//}
//if resp == nil || resp.Id == 0 {
filter := bson.M{
richtextcontentmod.FN.RichTextContentModel.Id: richtextId,
}
richtextInfo, serr := richtextcontentmod.FindOneRichTextContentModelModel(ctx, filter)
if serr != nil {
log.Error("VideoTagData", log.Any("videoFilter", filter), log.E(serr))
return proto.RichTextInfo{}, serr
}
data := &proto.RichTextInfo{
Id: richtextInfo.Id,
Title: richtextInfo.Title,
Content: richtextInfo.Content,
Desc: richtextInfo.Desc,
SeoInfo: richtextInfo.SeoInfo,
}
resp = data
// Cache.Set(cacheKey, data, 17*time.Minute)
//}
return *resp, nil
}
func IncWatchTimes(ctx context.Context, videoId int64) {
_ = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: videoId,
}, bson.M{"$inc": bson.M{
videoinformationmod.FN.VideoInformation.Real.WatchTimes: 1,
}})
}
func VideoTopicData(ctx context.Context, isAv bool, topicId int64, sortType int64, pageNum int64) (videoCount int64, videoInfos []proto.VideoInfoData, sortItem videoinformationmod.VideoSortItem, err *srvdef.SrvErr) {
imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx))
videoCount = int64(0)
sort := videoinformationmod.VideoSortType(sortType)
sortItem = sort.GetSortItem()
videoInfos = make([]proto.VideoInfoData, 0)
filter := bson.M{
videoinformationmod.FN.VideoInformation.MediaType: videoinformationmod.MediaType_VideoV2,
videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
}
opts := &options.FindOptions{}
opts.SetLimit(pubdef.PageSize).SetSkip((pageNum - 1) * pubdef.PageSize).SetSort(sort.GetSortBson())
videoCount, _ = videoinformationmod.CountVideoInformations(ctx, filter)
if videoCount == 0 {
return
}
videoList, err := videoinformationmod.FindManyVideoInformations(ctx, filter, opts)
if err != nil {
log.Warn("VideoTopicData FindManyVideoInfomations", log.Any("filter", filter), log.E(err))
return
}
for i := 0; i < len(videoList); i++ {
info := toVideoInfoData(videoList[i], imgCdn)
for _, tid := range videoList[i].Tags {
tagInfo := configser.GetTag(tid)
if tagInfo != nil {
info.Tags = append(info.Tags, proto.TagsInfoData{
Id: tagInfo.Id,
Name: tagInfo.Name,
})
}
}
if info.WatchTimes > 0 {
info.WatchTimes = info.WatchTimes * 100
} else {
info.WatchTimes = utils.GetRandom(2000, 10000)
}
videoInfos = append(videoInfos, info)
}
return
}