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 }