package srv_mediaresource import ( "context" "math/rand" "strings" "time" "videoweb/pub/common/log" "videoweb/pub/common/slugx" "videoweb/pub/common/srvdef" "videoweb/pub/common/utils" "videoweb/pub/models/mwacc" "videoweb/pub/models/playletchaptermod" "videoweb/pub/models/playletmod" "videoweb/web/api/proto" "videoweb/web/middleware/auth" "videoweb/web/webg" "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" ) const ( defaultResourceSyncType = 1 defaultResourcePageNum = 1 defaultResourcePageSize = 20 maxResourcePageSize = 100 maxResourceImportIds = 100 playletResourceImportMsg = "同步任务已提交,后台正在处理" playletHeatMinValue = 1000 playletHeatMaxValue = 5000 playletResourceMediaTypeAnime = 1 // 动漫 playletResourceMediaTypeComic = 4 // 漫画短剧 playletResourceMediaTypeShort = 5 // 短剧 playletResourceMediaTypeNormal = 6 // 普通短剧 ) // ListPlayletResource 分页查询短剧媒资列表 func ListPlayletResource(ctx context.Context, req *proto.PlayletResourceListReq) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } syncType := defaultResourceSyncType if req.SyncType != nil { syncType = *req.SyncType } pageNumber := req.PageNumber if pageNumber <= 0 { pageNumber = defaultResourcePageNum } pageSize := req.PageSize if pageSize <= 0 { pageSize = defaultResourcePageSize } if pageSize > maxResourcePageSize { pageSize = maxResourcePageSize } mediaType, err1 := resolvePlayletResourceMediaType(req.MediaType) if err1 != nil { return nil, err1 } ms := newPlayletMediaCenterFromConfig() data, err := ms.mediaList(&playletMediaListReq{ MediaType: mediaType, Title: req.Title, Tag: req.Tag, SyncType: syncType, Page: uint(pageNumber), Size: uint(pageSize), }) if err != nil { return nil, srvdef.New(srvdef.Failure, err.Error(), "") } if err := fillPlayletResourceAddStatus(ctx, data.Data); err != nil { return nil, err } return &proto.PlayletResourceListResp{ List: data.Data, Total: data.Pagination.Count, }, nil } // ListPlayletResourceChapters 查询短剧媒资剧集信息 func ListPlayletResourceChapters(ctx context.Context, req *proto.PlayletResourceChapterListReq) (*proto.PlayletResourceChapterListResp, *srvdef.SrvErr) { if req == nil || req.Id == 0 { return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "") } ms := newPlayletMediaCenterFromConfig() result, err := ms.mediaSync(&playletMediaSyncReq{ Id: req.Id, SyncTime: 0, }) if err != nil { return nil, srvdef.New(srvdef.Failure, err.Error(), "") } if result.IsDelete || result.Media == nil { return nil, srvdef.New(srvdef.ErrNoData, "媒资不存在或已删除", "") } list := make([]*proto.PlayletResourceChapterItem, 0, len(result.MediaContentList)) for _, item := range result.MediaContentList { videoUrl := strings.TrimSpace(item.M3u8Url) if videoUrl == "" { videoUrl = strings.TrimSpace(item.Urls) } list = append(list, &proto.PlayletResourceChapterItem{ Id: item.ID, EpisodeNumber: item.EpisodeNumber, Title: strings.TrimSpace(item.Title), Cover: item.Cover, VideoUrl: videoUrl, Height: item.Height, Width: item.Width, MediaSize: item.MediaSize, PlayTime: item.PlayTime, SaleCoin: item.SaleCoin, HashId: item.HashId, CreateTime: item.CreateTime, UpdateTime: item.UpdateTime, }) } return &proto.PlayletResourceChapterListResp{ MediaId: req.Id, Title: result.Media.Title, List: list, Total: len(list), }, nil } // resolvePlayletResourceMediaType 校验后台显式传入的媒资类型 func resolvePlayletResourceMediaType(mediaType *int) (int, *srvdef.SrvErr) { if mediaType == nil { return 0, srvdef.New(srvdef.ErrParamError, "媒体类型不能为空", "媒体类型不能为空") } if !validPlayletResourceMediaType(*mediaType) { return 0, srvdef.New(srvdef.ErrParamError, "媒体类型无效,仅支持1-动漫、2-漫画、4-漫画短剧、5-短剧、6-普通短剧", "媒体类型无效,仅支持1-动漫、2-漫画、4-漫画短剧、5-短剧、6-普通短剧") } return *mediaType, nil } // validPlayletResourceMediaType 媒资列表当前允许的媒体类型 func validPlayletResourceMediaType(mediaType int) bool { switch mediaType { case playletResourceMediaTypeAnime, playletResourceMediaTypeComic, playletResourceMediaTypeShort, playletResourceMediaTypeNormal: return true default: return false } } // validPlayletImportMediaType 允许入库的媒资类型 func validPlayletImportMediaType(mediaType int) bool { switch mediaType { case playletResourceMediaTypeAnime, playletResourceMediaTypeComic, playletResourceMediaTypeShort, playletResourceMediaTypeNormal: return true default: return false } } // contentTypeFromMediaType 媒资中心 media_type 映射为本项目 contentType func contentTypeFromMediaType(mediaType int) playletmod.ContentType { switch mediaType { case playletResourceMediaTypeAnime: return playletmod.ContentTypeAnime case playletResourceMediaTypeComic: return playletmod.ContentTypeComic case playletResourceMediaTypeNormal: return playletmod.ContentTypeAlbum default: return playletmod.ContentTypeTheater } } // fillPlayletResourceAddStatus 回填媒资列表是否已加入后台短剧库 func fillPlayletResourceAddStatus(ctx context.Context, list []*proto.PlayletResourceListItem) *srvdef.SrvErr { mediaIds := make([]uint, 0, len(list)) seen := make(map[uint]struct{}, len(list)) for _, item := range list { if item == nil || item.ID == 0 { continue } if _, ok := seen[item.ID]; ok { continue } seen[item.ID] = struct{}{} mediaIds = append(mediaIds, item.ID) } if len(mediaIds) == 0 { return nil } playletList, err := playletmod.FindManyPlaylet(ctx, bson.M{ playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": mediaIds}, }) if err != nil { log.Error("查询短剧媒资加入状态失败", log.Any("mediaIds", mediaIds), log.Any("err", err)) return err } addedMap := make(map[uint]bool, len(playletList)) for _, item := range playletList { if item == nil || item.MediaCenterId == 0 { continue } addedMap[item.MediaCenterId] = true } for _, item := range list { if item != nil { item.IsAdd = addedMap[item.ID] } } return nil } // ImportPlayletResource 提交短剧媒资入库任务 func ImportPlayletResource(ctx context.Context, req *proto.PlayletResourceUseReq) (string, *srvdef.SrvErr) { ids, err := validatePlayletResourceImportReq(req) if err != nil { return "", err } operatorId, operatorName := resolveImportOperator(ctx) if err := pushPlayletResourceImportJob(ctx, ids, operatorId, operatorName); err != nil { return "", err } return playletResourceImportMsg, nil } // importPlayletResourceBatch 批量处理短剧媒资入库任务 func importPlayletResourceBatch(ctx context.Context, ids []uint, operatorId int64, operatorName string) *proto.PlayletResourceUseResp { resp := &proto.PlayletResourceUseResp{ Success: make([]*proto.PlayletResourceUseSuccess, 0, len(ids)), Failed: make([]*proto.PlayletResourceUseFailed, 0), } importedMap, err := findImportedPlayletResourceMap(ctx, ids) if err != nil { for _, id := range ids { resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ MediaId: id, Reason: "查询导入状态失败", }) } return resp } ms := newPlayletMediaCenterFromConfig() for _, id := range ids { if importedMap[id] { resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ MediaId: id, Reason: "该媒资已导入", }) continue } success, reason := importSinglePlayletResource(ctx, ms, id, operatorId, operatorName) if reason != "" { resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ MediaId: id, Reason: reason, }) continue } resp.Success = append(resp.Success, success) } return resp } // findImportedPlayletResourceMap 批量查询已经导入的媒资ID func findImportedPlayletResourceMap(ctx context.Context, ids []uint) (map[uint]bool, *srvdef.SrvErr) { result := make(map[uint]bool, len(ids)) if len(ids) == 0 { return result, nil } playletList, err := playletmod.FindManyPlaylet(ctx, bson.M{ playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": ids}, }) if err != nil { log.Error("批量查询短剧媒资导入状态失败", log.Any("mediaIds", ids), log.Any("err", err)) return nil, err } for _, item := range playletList { if item == nil || item.MediaCenterId == 0 { continue } result[item.MediaCenterId] = true } return result, nil } func newPlayletMediaCenterFromConfig() *playletMediaCenterService { return newPlayletMediaCenterService(playletMediaCenterConfig{ Url: webg.Conf.MediaResourceDBCfg.BaseUrl, ProId: webg.Conf.MediaResourceDBCfg.Merchat, Secret: webg.Conf.MediaResourceDBCfg.Secret, }) } // validatePlayletResourceImportReq 校验短剧媒资入库请求 func validatePlayletResourceImportReq(req *proto.PlayletResourceUseReq) ([]uint, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePlayletResourceIds(req.Ids) if len(ids) == 0 { return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "") } if len(ids) > maxResourceImportIds { return nil, srvdef.New(srvdef.ErrParamError, "单次最多导入100条媒资", "") } return ids, nil } // dedupePlayletResourceIds 去重并过滤无效媒资ID func dedupePlayletResourceIds(ids []uint) []uint { result := make([]uint, 0, len(ids)) seen := make(map[uint]struct{}, len(ids)) for _, id := range ids { if id == 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} result = append(result, id) } return result } // importSinglePlayletResource 导入单条短剧媒资 func importSinglePlayletResource(ctx context.Context, ms *playletMediaCenterService, mediaId uint, operatorId int64, operatorName string) (*proto.PlayletResourceUseSuccess, string) { locked, err := lockPlayletResourceImportMedia(mediaId) if err != nil { log.Error("获取短剧媒资导入锁失败", log.Any("mediaId", mediaId), log.E(err)) return nil, "获取导入锁失败" } if !locked { return nil, "该媒资正在导入" } defer unlockPlayletResourceImportMedia(mediaId) if reason := checkPlayletResourceImported(ctx, mediaId); reason != "" { return nil, reason } result, err := ms.mediaSync(&playletMediaSyncReq{ Id: mediaId, SyncTime: 0, }) if err != nil { log.Error("同步短剧媒资失败", log.Any("mediaId", mediaId), log.E(err)) return nil, "同步媒资失败" } if result.IsDelete || result.Media == nil { return nil, "媒资不存在或已删除" } if !validPlayletImportMediaType(result.Media.MediaType) { return nil, "该媒资类型不支持导入短剧" } if len(result.MediaContentList) == 0 { return nil, "媒资章节为空" } playlet := buildPlayletFromMedia(result.Media, operatorId, operatorName) playlet.MediaSyncTime = maxPlayletMediaContentUpdateTime(0, result.MediaContentList) if playlet.Title == "" { return nil, "媒资标题为空" } exists := func(s string) bool { item, _ := playletmod.FindOnePlaylet(ctx, slugx.Filter(playletmod.FN.Playlet.Slug, playletmod.FN.Playlet.Id, s, 0)) return item != nil } if serr := slugx.Ensure(&playlet.Slug, utils.SlugSourceFromKeywordsOrTitle(nil, playlet.Title), exists); serr != nil { if serr.Tips != "" { return nil, serr.Tips } return nil, "slug生成失败" } // 原图继续写入 Cover;压缩成功才写 CompressedCover,失败保持空。 playlet.CompressedCover = compressPlayletCoverOrEmpty(playlet.Cover) chapters := buildPlayletChaptersFromMedia(result.MediaContentList) if len(chapters) == 0 { return nil, "媒资章节为空" } if err1 := playletmod.InsertOnePlaylet(ctx, playlet); err1 != nil { log.Error("短剧媒资主表入库失败", log.Any("mediaId", mediaId), log.Any("err", err1)) return nil, "主资源入库失败" } for _, chapter := range chapters { chapter.PlayletId = playlet.Id } if err1 := playletchaptermod.InsertManyPlayletChapter(ctx, chapters); err1 != nil { log.Error("短剧媒资章节批量入库失败", log.Any("mediaId", mediaId), log.Any("playletId", playlet.Id), log.Any("chapterCount", len(chapters)), log.Any("err", err1)) rollbackImportedPlayletResource(ctx, playlet.Id, collectPlayletChapterIds(chapters)) return nil, "章节入库失败" } return &proto.PlayletResourceUseSuccess{ MediaId: mediaId, PlayletId: playlet.Id, }, "" } // collectPlayletChapterIds 收集章节ID func collectPlayletChapterIds(chapters playletchaptermod.PlayletChapterList) []int64 { ids := make([]int64, 0, len(chapters)) for _, chapter := range chapters { if chapter == nil || chapter.Id == 0 { continue } ids = append(ids, chapter.Id) } return ids } // checkPlayletResourceImported 检查媒资是否已经导入到本项目短剧表 func checkPlayletResourceImported(ctx context.Context, mediaId uint) string { _, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.MediaCenterId: mediaId}) if err == nil { return "该媒资已导入" } if err.Code != srvdef.ErrNoData { log.Error("查询短剧媒资导入状态失败", log.Any("mediaId", mediaId), log.Any("err", err)) return "查询导入状态失败" } return "" } // buildPlayletFromMedia 将媒资中心主资源转换为本项目 playlet 主表模型 func buildPlayletFromMedia(media *proto.PlayletResourceListItem, operatorId int64, operatorName string) *playletmod.Playlet { cover := strings.TrimSpace(media.VerticalCover) if cover == "" { cover = strings.TrimSpace(media.HorizontalCover) } return &playletmod.Playlet{ MediaCenterId: uint(media.ID), MediaCreatedAt: mediaResourceTime(media.CreateTime), MediaUpdatedAt: mediaResourceTime(media.UpdateTime), Title: strings.TrimSpace(media.Title), Desc: strings.TrimSpace(media.Summary), Anchor: strings.TrimSpace(media.Anchor), Cover: cover, OldTags: oldTagNamesFromPlayletTagsText(media.TagsText), MediaBusiness: playletMediaBusiness(media), ContentType: contentTypeFromMediaType(media.MediaType), TotalEpisode: int64(media.TotalEpisode), UpdateStatus: normalizePlayletUpdateStatus(media.UpdateStatus), HeatValue: randomPlayletHeatValue(), Status: playletmod.StatusDown, OperatorId: operatorId, OperatorName: operatorName, } } // playletMediaBusinessCGDJ 赤果短剧、CGDJ 标题前缀对应的媒资业务来源。 const playletMediaBusinessCGDJ = "CGDJ" // playletMediaBusinessRule 把媒资标题前缀映射成要写入的业务来源。先匹配先生效。 type playletMediaBusinessRule struct { Prefix string Business string } // playletMediaBusinessRules 多个前缀可以对应同一个业务来源。 // 新增来源时在这里追加;同一来源的不同叫法共用同一个 Business。 var playletMediaBusinessRules = []playletMediaBusinessRule{ //{Prefix: "赤果短剧", Business: playletMediaBusinessCGDJ}, {Prefix: "CGDJ", Business: playletMediaBusinessCGDJ}, } func playletMediaBusiness(media *proto.PlayletResourceListItem) string { if media == nil || media.MediaType != playletResourceMediaTypeShort { return "" } title := strings.ToUpper(strings.TrimSpace(media.Title)) for _, rule := range playletMediaBusinessRules { prefix := strings.ToUpper(strings.TrimSpace(rule.Prefix)) business := strings.TrimSpace(rule.Business) if prefix == "" || business == "" { continue } if strings.HasPrefix(title, prefix) { return business } } return "" } // playletMediaBusinessSearchTitles 返回回填时用来搜索媒资库的标题前缀。 func playletMediaBusinessSearchTitles() []string { seen := make(map[string]struct{}, len(playletMediaBusinessRules)) titles := make([]string, 0, len(playletMediaBusinessRules)) for _, rule := range playletMediaBusinessRules { prefix := strings.TrimSpace(rule.Prefix) if prefix == "" { continue } key := strings.ToUpper(prefix) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} titles = append(titles, prefix) } return titles } func oldTagNamesFromPlayletTagsText(tagsText string) []string { fields := strings.FieldsFunc(strings.TrimSpace(tagsText), func(r rune) bool { switch r { case ',', ',', '、', ';', ';', '/', '|': return true default: return false } }) names := make([]string, 0, len(fields)) seen := make(map[string]struct{}, len(fields)) for _, field := range fields { name := strings.TrimSpace(field) if name == "" { continue } if _, ok := seen[name]; ok { continue } seen[name] = struct{}{} names = append(names, name) } return names } func randomPlayletHeatValue() int64 { return int64(rand.Intn(playletHeatMaxValue-playletHeatMinValue+1) + playletHeatMinValue) } // mediaResourceTime 转换媒资中心时间戳 func mediaResourceTime(ts int64) time.Time { if ts <= 0 { return time.Time{} } if ts > 1e12 { return time.UnixMilli(ts) } return time.Unix(ts, 0) } // buildPlayletChaptersFromMedia 将媒资中心章节资源转换为本项目 playlet_chapter 模型 func buildPlayletChaptersFromMedia(mediaContentList []playletMediaContent) playletchaptermod.PlayletChapterList { chapters := make(playletchaptermod.PlayletChapterList, 0, len(mediaContentList)) seenMediaContentIds := make(map[uint]struct{}, len(mediaContentList)) seenEpisodes := make(map[int]struct{}, len(mediaContentList)) seenHashIds := make(map[string]struct{}, len(mediaContentList)) seenVideoUrls := make(map[string]struct{}, len(mediaContentList)) for _, item := range mediaContentList { videoUrl := strings.TrimSpace(item.M3u8Url) if videoUrl == "" { videoUrl = strings.TrimSpace(item.Urls) } if videoUrl == "" { continue } if isDuplicatePlayletMediaChapter(item, videoUrl, seenMediaContentIds, seenEpisodes, seenHashIds, seenVideoUrls) { continue } episodeNumber := int64(item.EpisodeNumber) if episodeNumber <= 0 { episodeNumber = int64(len(chapters) + 1) } height := int64(item.Height) width := int64(item.Width) ratio := item.Ratio if ratio <= 0 && height > 0 { ratio = float64(width) / float64(height) } chapters = append(chapters, &playletchaptermod.PlayletChapter{ MediaContentId: uint(item.ID), CurrentEpisode: episodeNumber, Title: strings.TrimSpace(item.Title), Hash: item.HashId, Height: height, Width: width, Ratio: ratio, VideoUrl: videoUrl, MediaSize: int64(item.MediaSize), MediaTime: int64(item.PlayTime), PayType: playletchaptermod.PayTypeFree, SingleCoins: int64(item.SaleCoin), WatermarkStatus: playletchaptermod.WatermarkStatusPending, }) } return chapters } // isDuplicatePlayletMediaChapter 判断媒资中心章节是否重复 func isDuplicatePlayletMediaChapter(item playletMediaContent, videoUrl string, seenMediaContentIds map[uint]struct{}, seenEpisodes map[int]struct{}, seenHashIds map[string]struct{}, seenVideoUrls map[string]struct{}) bool { if item.ID > 0 { if _, ok := seenMediaContentIds[item.ID]; ok { log.Info("跳过重复短剧媒资章节", log.Any("mediaContentId", item.ID), log.Any("episodeNumber", item.EpisodeNumber)) return true } seenMediaContentIds[item.ID] = struct{}{} return false } if item.EpisodeNumber > 0 { if _, ok := seenEpisodes[item.EpisodeNumber]; ok { log.Info("跳过重复短剧媒资章节", log.Any("episodeNumber", item.EpisodeNumber)) return true } seenEpisodes[item.EpisodeNumber] = struct{}{} return false } hashId := strings.TrimSpace(item.HashId) if hashId != "" { if _, ok := seenHashIds[hashId]; ok { log.Info("跳过重复短剧媒资章节", log.Any("hashId", hashId)) return true } seenHashIds[hashId] = struct{}{} return false } if _, ok := seenVideoUrls[videoUrl]; ok { log.Info("跳过重复短剧媒资章节", log.Any("videoUrl", videoUrl)) return true } seenVideoUrls[videoUrl] = struct{}{} return false } // rollbackImportedPlayletResource 回滚单条媒资入库产生的数据 func rollbackImportedPlayletResource(ctx context.Context, playletId int64, chapterIds []int64) { for _, chapterId := range chapterIds { if _, err := playletchaptermod.DelOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: chapterId}); err != nil { log.Error("回滚短剧媒资章节失败", log.Any("playletId", playletId), log.Any("chapterId", chapterId), log.Any("err", err)) } } if _, err := playletmod.DelOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playletId}); err != nil { log.Error("回滚短剧媒资主表失败", log.Any("playletId", playletId), log.Any("err", err)) } } // resolveImportOperator 从后台登录态解析当前操作人 func resolveImportOperator(ctx context.Context) (int64, string) { c, ok := ctx.(*gin.Context) if !ok || c == nil { return 0, "" } accId := auth.GetAccount(c) if accId <= 0 { return 0, "" } acc, err := mwacc.FindOneAccByFilter(c, bson.M{ mwacc.FN.MwAcc.Id: accId, }, options.FindOne().SetProjection(bson.D{ {Key: mwacc.FN.MwAcc.Id, Value: 1}, {Key: mwacc.FN.MwAcc.UserName, Value: 1}, })) if err != nil || acc == nil { return accId, "" } return acc.Id, acc.UserName } // maxPlayletMediaContentUpdateTime 记录本次同步返回章节中的最大更新时间 func maxPlayletMediaContentUpdateTime(syncTime int64, mediaContentList []playletMediaContent) int64 { result := syncTime for _, item := range mediaContentList { if item.UpdateTime > result { result = item.UpdateTime } } return result } // normalizePlayletUpdateStatus 三方 update_status=0 在本项目按 2-已完结保存 func normalizePlayletUpdateStatus(updateStatus int) int { if updateStatus == 0 { return 2 } return updateStatus }