342 lines
11 KiB
Go
342 lines
11 KiB
Go
package dramaser
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"math/rand"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"91porn-server/app/appg"
|
|
"91porn-server/app/service/mediacontentser"
|
|
"91porn-server/app/service/mediaser"
|
|
recommendqueue "91porn-server/common/shortrecommend"
|
|
"91porn-server/models/cache/mediadata"
|
|
"91porn-server/models/v/media_buy_record_mod"
|
|
"91porn-server/models/v/mediacontentmod"
|
|
"91porn-server/models/v/mediamod"
|
|
"91porn-server/models/v/moduleconfmod"
|
|
"91porn-server/models/v/vidmod"
|
|
|
|
"go.mongodb.org/mongo-driver/bson"
|
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
|
"go.mongodb.org/mongo-driver/mongo/options"
|
|
)
|
|
|
|
const dramaFeedCandidateLimit = 500
|
|
|
|
func GetChannelConfig(now time.Time) (ChannelConfig, error) {
|
|
modules, err := moduleconfmod.GetModuleConfByType(moduleconfmod.Drama)
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
active := make([]moduleconfmod.ModuleConf, 0, len(modules))
|
|
for _, module := range modules {
|
|
if module.IsActiveAt(now) {
|
|
active = append(active, module)
|
|
}
|
|
}
|
|
sort.SliceStable(active, func(i, j int) bool { return active[i].SortNum > active[j].SortNum })
|
|
var aiModuleID, hotModuleID primitive.ObjectID
|
|
for _, module := range active {
|
|
name := strings.ToLower(strings.TrimSpace(module.ModuleName))
|
|
switch {
|
|
case strings.Contains(name, "热门") || strings.Contains(name, "hot"):
|
|
if hotModuleID.IsZero() {
|
|
hotModuleID = module.ID
|
|
}
|
|
case strings.Contains(name, "ai"):
|
|
if aiModuleID.IsZero() {
|
|
aiModuleID = module.ID
|
|
}
|
|
}
|
|
}
|
|
if aiModuleID.IsZero() && len(active) > 0 {
|
|
aiModuleID = active[0].ID
|
|
}
|
|
if hotModuleID.IsZero() && len(active) > 1 {
|
|
hotModuleID = active[1].ID
|
|
}
|
|
return ChannelConfig{
|
|
BottomTabTitle: "短剧",
|
|
DefaultTab: TabAIDrama,
|
|
AdEnabled: false,
|
|
Tabs: []ChannelTab{
|
|
{Key: TabLive, Title: "直播", Enabled: true, Sort: 1},
|
|
{Key: TabDouyin, Title: "抖音", Enabled: true, Sort: 2},
|
|
{Key: TabAIDrama, Title: "AI短剧", Enabled: !aiModuleID.IsZero(), Sort: 3, ModuleID: objectIDString(aiModuleID)},
|
|
{Key: TabHotDrama, Title: "热门短剧", Enabled: !hotModuleID.IsZero(), Sort: 4, ModuleID: objectIDString(hotModuleID)},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func objectIDString(id primitive.ObjectID) string {
|
|
if id.IsZero() {
|
|
return ""
|
|
}
|
|
return id.Hex()
|
|
}
|
|
|
|
func GetList(uid uint64, req ListRequest) (ListResponse, error) {
|
|
filter := bson.M{"mediaType": mediamod.MediaTypeDrama, "status": 1, "isDelete": false}
|
|
sortBy := dramaListSort(req.SortType)
|
|
total, err := mediamod.QueryAllCount(filter)
|
|
if err != nil {
|
|
return ListResponse{}, err
|
|
}
|
|
list, hasNext, err := mediadata.GetListFromCache(filter, req.Skip64(), req.Limit64(), sortBy)
|
|
if err != nil {
|
|
return ListResponse{}, err
|
|
}
|
|
return ListResponse{Total: total, HasNext: hasNext, List: mediaser.FillMedias(list, uid, true)}, nil
|
|
}
|
|
|
|
func dramaListSort(sortType int) bson.D {
|
|
sortBy := bson.D{{Key: "sortCode", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}}
|
|
switch sortType {
|
|
case 2:
|
|
sortBy = bson.D{{Key: "latestPublishedAt", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}}
|
|
case 3:
|
|
sortBy = bson.D{{Key: "countLike", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}}
|
|
case 4:
|
|
sortBy = bson.D{{Key: "countCollect", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}}
|
|
}
|
|
return sortBy
|
|
}
|
|
|
|
func GetFeed(ctx context.Context, uid uint64, pageSize uint64, now time.Time) (FeedResponse, error) {
|
|
filter := bson.M{"mediaType": mediamod.MediaTypeDrama, "status": 1, "isDelete": false}
|
|
candidates, _, err := mediadata.GetListFromCache(
|
|
filter, 0, dramaFeedCandidateLimit,
|
|
bson.D{{Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}},
|
|
)
|
|
if err != nil {
|
|
return FeedResponse{}, err
|
|
}
|
|
ordered := orderFeedCandidates(candidates, now)
|
|
if len(ordered) == 0 {
|
|
return FeedResponse{QueueVersion: "drama-" + recommendqueue.VersionAt(now), List: []FeedItem{}}, nil
|
|
}
|
|
queueIDs := make([]string, 0, len(ordered))
|
|
mediaByID := make(map[string]*mediamod.Media, len(ordered))
|
|
for _, media := range ordered {
|
|
if media == nil {
|
|
continue
|
|
}
|
|
id := media.ID.Hex()
|
|
queueIDs = append(queueIDs, id)
|
|
mediaByID[id] = media
|
|
}
|
|
queueVersion := dramaFeedQueueVersion(now, queueIDs)
|
|
reservation, err := reserveDramaFeedPage(
|
|
ctx, appg.Redis, uid, int(pageSize)*dramaFeedScanMultiplier, queueVersion, queueIDs,
|
|
)
|
|
if err != nil {
|
|
return FeedResponse{}, err
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
abortCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = abortDramaFeedPage(abortCtx, appg.Redis, uid, reservation)
|
|
}
|
|
}()
|
|
reservedMedias := make([]*mediamod.Media, 0, len(reservation.IDs))
|
|
for _, id := range reservation.IDs {
|
|
if media := mediaByID[id]; media != nil {
|
|
reservedMedias = append(reservedMedias, media)
|
|
}
|
|
}
|
|
hasCard, err := mediacontentser.ActiveDramaCardStatus(uid)
|
|
if err != nil {
|
|
return FeedResponse{}, err
|
|
}
|
|
mediaResponses := mediaser.FillMedias(reservedMedias, uid, true, hasCard)
|
|
episodes, err := firstEpisodeMap(uid, reservedMedias, hasCard)
|
|
if err != nil {
|
|
return FeedResponse{}, err
|
|
}
|
|
responseByID := make(map[string]*mediamod.AppMediaBase, len(mediaResponses))
|
|
for _, media := range mediaResponses {
|
|
if media != nil {
|
|
responseByID[media.ID.Hex()] = media
|
|
}
|
|
}
|
|
items := make([]FeedItem, 0, pageSize)
|
|
scanned := 0
|
|
for _, id := range reservation.IDs {
|
|
scanned++
|
|
media := responseByID[id]
|
|
if media == nil {
|
|
continue
|
|
}
|
|
episode, ok := episodes[media.ID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
items = append(items, FeedItem{Media: media, Content: episode})
|
|
if len(items) >= int(pageSize) {
|
|
break
|
|
}
|
|
}
|
|
if err := commitDramaFeedPage(ctx, appg.Redis, uid, reservation, scanned); err != nil {
|
|
return FeedResponse{}, err
|
|
}
|
|
committed = true
|
|
return FeedResponse{
|
|
QueueVersion: "drama-" + reservation.Version,
|
|
HasNext: reservation.Length > len(items),
|
|
List: items,
|
|
}, nil
|
|
}
|
|
|
|
// dramaFeedQueueVersion 将当天候选集快照写入版本。上架、下架导致候选集变化时,
|
|
// 新请求会切换到独立队列;ID 排序后再计算,避免仅推荐顺序变化造成队列频繁重建。
|
|
func dramaFeedQueueVersion(now time.Time, queueIDs []string) string {
|
|
ids := append([]string(nil), queueIDs...)
|
|
sort.Strings(ids)
|
|
h := fnv.New64a()
|
|
for _, id := range ids {
|
|
_, _ = h.Write([]byte(id))
|
|
_, _ = h.Write([]byte{0})
|
|
}
|
|
return recommendqueue.VersionWithRevision(now, fmt.Sprintf("%016x", h.Sum64()))
|
|
}
|
|
|
|
func orderFeedCandidates(candidates []*mediamod.Media, now time.Time) []*mediamod.Media {
|
|
recommended := make([]*mediamod.Media, 0)
|
|
regular := make([]vidmod.RecommendCandidate, 0)
|
|
mediaByID := make(map[primitive.ObjectID]*mediamod.Media, len(candidates))
|
|
for _, media := range candidates {
|
|
if media == nil {
|
|
continue
|
|
}
|
|
mediaByID[media.ID] = media
|
|
candidate := vidmod.RecommendCandidate{
|
|
ID: media.ID, ReviewAt: dramaReviewAt(media), RecommendScore: dramaInteractionScore(media),
|
|
}
|
|
if media.SortCode > 0 || !media.Choice.IsZero() {
|
|
recommended = append(recommended, media)
|
|
continue
|
|
}
|
|
regular = append(regular, candidate)
|
|
}
|
|
rng := rand.New(rand.NewSource(dramaQueueSeed(now)))
|
|
rng.Shuffle(len(recommended), func(i, j int) {
|
|
recommended[i], recommended[j] = recommended[j], recommended[i]
|
|
})
|
|
// 直接复用91P短视频队列的17条互动高分+3条24小时新内容组装算法。
|
|
regularIDs := recommendqueue.Assemble(regular, now)
|
|
ordered := make([]*mediamod.Media, 0, len(candidates))
|
|
ordered = append(ordered, recommended...)
|
|
ordered = appendDramaIDs(ordered, regularIDs, mediaByID)
|
|
return ordered
|
|
}
|
|
|
|
func dramaQueueSeed(now time.Time) int64 {
|
|
h := fnv.New64a()
|
|
_, _ = h.Write([]byte("drama:" + recommendqueue.VersionAt(now)))
|
|
return int64(h.Sum64())
|
|
}
|
|
|
|
func dramaInteractionScore(media *mediamod.Media) int64 {
|
|
if media == nil {
|
|
return 0
|
|
}
|
|
return vidmod.CalculateRecommendScore(media.CountLike, media.CountCollect, media.CountComment, media.CountShare)
|
|
}
|
|
|
|
func dramaReviewAt(media *mediamod.Media) time.Time {
|
|
if media != nil && !media.LatestPublishedAt.IsZero() {
|
|
return media.LatestPublishedAt
|
|
}
|
|
if media == nil {
|
|
return time.Time{}
|
|
}
|
|
return media.CreatedAt
|
|
}
|
|
|
|
func appendDramaIDs(
|
|
ordered []*mediamod.Media,
|
|
ids []string,
|
|
mediaByID map[primitive.ObjectID]*mediamod.Media,
|
|
) []*mediamod.Media {
|
|
for _, rawID := range ids {
|
|
id, err := primitive.ObjectIDFromHex(rawID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if media := mediaByID[id]; media != nil {
|
|
ordered = append(ordered, media)
|
|
}
|
|
}
|
|
return ordered
|
|
}
|
|
|
|
func firstEpisodeMap(uid uint64, medias []*mediamod.Media, hasCard bool) (map[primitive.ObjectID]mediacontentser.AppMediaContent, error) {
|
|
result := make(map[primitive.ObjectID]mediacontentser.AppMediaContent, len(medias))
|
|
if len(medias) == 0 {
|
|
return result, nil
|
|
}
|
|
mediaIDs := make([]primitive.ObjectID, 0, len(medias))
|
|
mediaMap := make(map[primitive.ObjectID]*mediamod.Media, len(medias))
|
|
for _, media := range medias {
|
|
if media != nil {
|
|
mediaIDs = append(mediaIDs, media.ID)
|
|
mediaMap[media.ID] = media
|
|
}
|
|
}
|
|
contents, err := mediacontentmod.QueryAllList(
|
|
bson.M{"mediaId": bson.M{"$in": mediaIDs}, "mediaType": mediamod.MediaTypeDrama, "episodeNumber": 1, "isActive": true, "isDelete": false},
|
|
options.Find().SetSort(bson.D{{Key: "createdAt", Value: 1}}),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
contentIDs := make([]primitive.ObjectID, 0, len(contents))
|
|
for _, content := range contents {
|
|
if content != nil {
|
|
contentIDs = append(contentIDs, content.ID)
|
|
}
|
|
}
|
|
bought, err := media_buy_record_mod.IsContentBuyBatch(uid, contentIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, content := range contents {
|
|
if content == nil || result[content.MediaID].ID != primitive.NilObjectID {
|
|
continue
|
|
}
|
|
media := mediaMap[content.MediaID]
|
|
if media == nil {
|
|
continue
|
|
}
|
|
isFree, canPlay, accessType := mediacontentser.ResolveDramaAccess(*content, *media, bought[content.ID], hasCard)
|
|
name := strings.TrimSpace(content.Name)
|
|
if name == "" {
|
|
name = fmt.Sprintf("第%d集", content.EpisodeNumber)
|
|
}
|
|
item := mediacontentser.AppMediaContent{
|
|
ID: content.ID, MediaID: content.MediaID, MediaType: content.MediaType,
|
|
EpisodeNumber: content.EpisodeNumber, ListenPermission: content.ListenPermission,
|
|
VideoUrl: content.VideoUrl, H265Url: appg.H265URLForApp(content.H265Url), AudioUrl: content.AudioUrl,
|
|
PlayTime: content.PlayTime, Price: content.Price, Name: name, Cover: content.Cover,
|
|
HasBuy: bought[content.ID], IsFree: isFree, HasDramaCard: hasCard,
|
|
CanPlay: canPlay, AccessType: accessType, Ratio: content.Ratio,
|
|
PreviewEnabled: content.PreviewEnabled, PreviewStart: content.PreviewStart, PreviewSeconds: content.PreviewSeconds,
|
|
CreatedAt: content.CreatedAt, UpdateTime: content.UpdateTime,
|
|
}
|
|
if !item.CanPlay {
|
|
if item.PreviewEnabled {
|
|
item.PreviewVideoUrl, item.PreviewH265Url = item.VideoUrl, item.H265Url
|
|
}
|
|
item.VideoUrl, item.H265Url, item.AudioUrl = "", "", ""
|
|
}
|
|
result[content.MediaID] = item
|
|
}
|
|
return result, nil
|
|
}
|