package job import ( "context" "errors" "fmt" "net/url" "strconv" "strings" "sync" "sync/atomic" "time" "91porn-server/common/constant" "91porn-server/common/constant/redisconst" "91porn-server/common/hevcpull" "91porn-server/common/laosiji" "91porn-server/common/log" "91porn-server/models/v/hevctaskmod" "91porn-server/models/v/moduleconfmod" "91porn-server/models/v/modulesectionmod" "91porn-server/models/v/modulevidmod" "91porn-server/models/v/sysconfmod" "91porn-server/models/v/vidmod" "91porn-server/skd/skdg" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo/options" ) const ( h265DefaultMaxRunning = 400 // 配置缺失或无效时,最多允许 400 个视频处于云端转码中 h265CheckBatchSize = 100 // 每轮最多轮询 100 个已提交的 H265 任务 h265HomeCandidateSize = 400 // 首页每个模块、每种排序最多保障 400 个视频 h265PendingTimeout = 240 * time.Hour h265PullURLTTL = 480 * time.Hour ) var ( h265SubmitRunning int32 h265CheckRunning int32 h265ScanRunning int32 h265MissingCredentialsLogOnce sync.Once h265MissingAppURLLogOnce sync.Once h265InvalidPullSecretLogOnce sync.Once ) // h265TranscodingJobEnabled 默认只允许生产环境执行真实云转码。 // 测试环境必须通过 hevc.enableTranscode 显式开启,避免误提交云端任务。 func h265TranscodingJobEnabled() bool { if skdg.Conf == nil { return false } log.Info("h265TranscodingJobEnabled ", log.Any("skdg.Conf.Base.Env", skdg.Conf.Base.Env)) log.Info("h265TranscodingJobEnabled ", log.Any("skdg.Conf.Hevc.EnableTranscode", skdg.Conf.Hevc.EnableTranscode)) if skdg.Conf.Hevc.EnableTranscode != nil && !*skdg.Conf.Hevc.EnableTranscode { return false } if skdg.Conf.Base.Env != constant.ProdEnv && (skdg.Conf.Hevc.EnableTranscode == nil || !*skdg.Conf.Hevc.EnableTranscode) { return false } if !laosiji.Configured() { h265MissingCredentialsLogOnce.Do(func() { log.Warn("H265 transcoding jobs disabled: laosiji credentials are incomplete") }) return false } if _, err := hevcpull.SignURL( "https://configuration-check.invalid/source.m3u8", skdg.Conf.Hevc.PullSecret, time.Now().Add(h265PullURLTTL), ); err != nil { h265InvalidPullSecretLogOnce.Do(func() { log.Warn("H265 transcoding jobs disabled: hevc.pullSecret is missing or invalid", log.E(err)) }) return false } if buildH265AppTranscodeM3u8URL( skdg.Conf.Url.AppApiUrl, "health-check.m3u8", skdg.Conf.Hevc.PullSecret, time.Now().Add(h265PullURLTTL), ) == "" { h265MissingAppURLLogOnce.Do(func() { log.Warn("H265 transcoding jobs disabled: url.appApiUrl is missing or invalid", log.Any("skdg.Conf.Url.AppApiUrl", skdg.Conf.Url.AppApiUrl)) }) return false } return true } // SubmitH265TranscodingQueue 把本地等待队列中的任务异步提交到云转码。 func SubmitH265TranscodingQueue() { if !h265TranscodingJobEnabled() { return } if !atomic.CompareAndSwapInt32(&h265SubmitRunning, 0, 1) { log.Info("SubmitH265TranscodingQueue skip: previous run still running") return } defer atomic.StoreInt32(&h265SubmitRunning, 0) ctx := context.Background() maxRunning := h265MaxRunning() pendingCount, err := vidmod.CountH265Pending() if err != nil { return } available := int64(maxRunning) - pendingCount if available <= 0 { return } domains, err := laosiji.SystemDomains(ctx) if err != nil { log.Warn("SubmitH265TranscodingQueue skip: laosiji domains failed", log.E(err)) return } videos, err := vidmod.GetQueuedH265Videos(available) if err != nil { return } for _, video := range videos { if err = submitH265Video(ctx, domains, video, maxRunning); err != nil { log.Warn("SubmitH265TranscodingQueue submit failed", log.Any("videoId", video.ID), log.E(err)) if errors.Is(err, laosiji.ErrTranscodeQueueFull) { return } } } } // CheckH265TranscodingResults 轮询云端任务并回写 h265Url。 func CheckH265TranscodingResults() { if !h265TranscodingJobEnabled() { return } if !atomic.CompareAndSwapInt32(&h265CheckRunning, 0, 1) { log.Info("CheckH265TranscodingResults skip: previous run still running") return } defer atomic.StoreInt32(&h265CheckRunning, 0) ctx := context.Background() domains, err := laosiji.SystemDomains(ctx) if err != nil { log.Warn("CheckH265TranscodingResults skip: laosiji domains failed", log.E(err)) return } videos, err := vidmod.GetPendingH265Videos(h265CheckBatchSize) if err != nil || len(videos) == 0 { return } now := time.Now() for _, video := range videos { if video == nil { continue } if h265PendingTimedOut(video, now) { timeoutMsg := fmt.Sprintf("H265 transcode pending timeout after %.0fh", h265PendingTimeout.Hours()) marked, markErr := vidmod.MarkH265Failed(video.ID, video.SourceURL, video.H265PendingAt) if markErr != nil { log.Warn("CheckH265TranscodingResults mark timeout failed", log.Any("videoId", video.ID), log.E(markErr)) } else if marked { upsertH265TaskWithError(video, domains, "", hevctaskmod.H265TaskStatusFailed, timeoutMsg) } else { log.Info("CheckH265TranscodingResults ignored stale timeout", log.Any("videoId", video.ID)) } continue } result, found, queryErr := queryH265Transcode(ctx, domains, video) if queryErr != nil { log.Warn("CheckH265TranscodingResults query failed", log.Any("videoId", video.ID), log.E(queryErr)) // 保留原 processing 审计状态。查询返回和日志已经足够诊断, // 此处不落库可避免并发成功后被旧查询错误覆盖。 continue } if !found { const notFoundMsg = "laosiji transcode task not found" log.Warn("CheckH265TranscodingResults task not found, retry as failed", log.Any("videoId", video.ID)) marked, markErr := vidmod.MarkH265Failed(video.ID, video.SourceURL, video.H265PendingAt) if markErr != nil { log.Warn("CheckH265TranscodingResults mark not-found failed", log.Any("videoId", video.ID), log.E(markErr)) } else if marked { // Failed 状态仍会进入等待队列,但会累计失败次数,避免云端永久查无任务时无限重提。 upsertH265TaskWithError(video, domains, "", hevctaskmod.H265TaskStatusFailed, notFoundMsg) } else { log.Info("CheckH265TranscodingResults ignored stale not-found result", log.Any("videoId", video.ID)) } continue } applyH265Result(video, result) } } func h265PendingTimedOut(video *vidmod.VideoModel, now time.Time) bool { if video == nil || video.H265Status != vidmod.H265StatusPending || video.H265PendingAt.IsZero() || video.H265PendingAt.After(now) { return false } return now.Sub(video.H265PendingAt) >= h265PendingTimeout } // ScanHomeH265TranscodingQueue 将首页各启用视频模块的重点 SP 视频加入等待队列。 func ScanHomeH265TranscodingQueue() { if !h265TranscodingJobEnabled() { return } if !atomic.CompareAndSwapInt32(&h265ScanRunning, 0, 1) { log.Info("ScanHomeH265TranscodingQueue skip: previous run still running") return } defer atomic.StoreInt32(&h265ScanRunning, 0) moduleIDs, moduleIDHexes := h265HomeModuleIDs() if len(moduleIDs) == 0 { return } videoIDs := make([]primitive.ObjectID, 0) seen := make(map[primitive.ObjectID]struct{}) for _, moduleIDHex := range moduleIDHexes { for _, sortType := range []int{1, 2, 3, 7, 9} { videos, err := vidmod.GetVideoListByCond(homeH265Filter(moduleIDHex, sortType), homeH265Options(sortType)) if err != nil { log.Warn("ScanHomeH265TranscodingQueue video query failed", log.Any("moduleId", moduleIDHex), log.Any("sortType", sortType), log.E(err)) continue } appendUniqueH265VideoIDs(&videoIDs, seen, videos) } } for _, moduleID := range moduleIDs { sections, err := modulesectionmod.GetAllBySubModuleID(moduleID) if err != nil { log.Warn("ScanHomeH265TranscodingQueue section query failed", log.Any("moduleId", moduleID), log.E(err)) continue } for _, section := range sections { sectionVideos, err := modulevidmod.SectionVideosBySectionID(section.ID, options.Find(). SetLimit(h265HomeCandidateSize). SetSort(bson.D{{Key: "sortCode", Value: -1}, {Key: "videoReviewedAt", Value: -1}})) if err != nil { log.Warn("ScanHomeH265TranscodingQueue section videos failed", log.Any("sectionId", section.ID), log.E(err)) continue } for _, sectionVideo := range sectionVideos { if _, exists := seen[sectionVideo.VideoID]; exists { continue } seen[sectionVideo.VideoID] = struct{}{} videoIDs = append(videoIDs, sectionVideo.VideoID) } } } modified, err := vidmod.QueueH265Transcode(videoIDs) if err != nil { log.Warn("ScanHomeH265TranscodingQueue enqueue failed", log.E(err)) return } if modified > 0 { log.Info("ScanHomeH265TranscodingQueue queued videos", log.Any("count", modified)) } } func submitH265Video(ctx context.Context, domains laosiji.SystemDomainsResp, video *vidmod.VideoModel, maxRunning int) error { if video == nil || strings.TrimSpace(video.SourceURL) == "" { return nil } claimedAt, claimed, err := vidmod.ClaimH265Pending(video.ID) if err != nil || !claimed { return err } video.H265PendingAt = claimedAt result, err := submitH265Transcode(ctx, domains, video, maxRunning) if err != nil { if errors.Is(err, laosiji.ErrTranscodeQueueFull) { marked, markErr := vidmod.MarkH265Queued(video.ID, video.SourceURL, video.H265PendingAt) if markErr != nil { log.Warn("submitH265Video restore queue failed", log.Any("videoId", video.ID), log.E(markErr)) } else if marked { upsertH265TaskWithError(video, domains, "", hevctaskmod.H265TaskStatusQueued, err.Error()) } return err } marked, markErr := vidmod.MarkH265Failed(video.ID, video.SourceURL, video.H265PendingAt) if markErr != nil { log.Warn("submitH265Video mark failed", log.Any("videoId", video.ID), log.E(markErr)) } else if marked { upsertH265TaskWithError(video, domains, "", hevctaskmod.H265TaskStatusFailed, err.Error()) } return err } applyH265Result(video, result) return nil } func submitH265Transcode(ctx context.Context, domains laosiji.SystemDomainsResp, video *vidmod.VideoModel, maxRunning int) (laosiji.TranscodeResult, error) { fileURL := hevcAppTranscodeM3u8URL(video.SourceURL, video.H265PendingAt) fileID := h265CloudFileID(video) if fileURL == "" || fileID == "" { return laosiji.TranscodeResult{}, fmt.Errorf("empty signed app transcode m3u8 url, videoId:%s", video.ID.Hex()) } return laosiji.SubmitH264ToH265Task(ctx, fileID, fileURL, domains, maxRunning) } func queryH265Transcode(ctx context.Context, domains laosiji.SystemDomainsResp, video *vidmod.VideoModel) (laosiji.TranscodeResult, bool, error) { fileURL := hevcAppTranscodeM3u8URL(video.SourceURL, video.H265PendingAt) fileID := h265CloudFileID(video) if fileURL == "" || fileID == "" { return laosiji.TranscodeResult{}, false, fmt.Errorf("empty signed app transcode m3u8 url, videoId:%s", video.ID.Hex()) } return laosiji.QueryH264ToH265Task(ctx, fileID, fileURL, domains) } // hevcAppTranscodeM3u8URL 使用 claim 时间生成稳定、限时且绑定源路径的 // HMAC 拉流 URL。云端 file_id 独立计算,签名或域名变化不会影响轮询。 func hevcAppTranscodeM3u8URL(source string, claimedAt time.Time) string { if skdg.Conf == nil { return "" } if claimedAt.IsZero() { return "" } return buildH265AppTranscodeM3u8URL( skdg.Conf.Url.AppApiUrl, source, skdg.Conf.Hevc.PullSecret, claimedAt.UTC().Truncate(time.Second).Add(h265PullURLTTL), ) } func buildH265AppTranscodeM3u8URL(baseURL, source, pullSecret string, expiresAt time.Time) string { baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") normalizedSource, sourceErr := hevcpull.NormalizeSource(source) if baseURL == "" || sourceErr != nil { return "" } parsed, err := url.Parse(baseURL) if err != nil || //parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.Fragment != "" { return "" } rawURL := baseURL + "/api/app/vid/transcode/m3u8/" + normalizedSource signedURL, err := hevcpull.SignURL(rawURL, pullSecret, expiresAt) if err != nil { return "" } return signedURL } // h265CloudFileID is independent from the temporary signed fetch URL. It // changes when the source or retry attempt changes, but not when App domains, // expiry timestamps, or signing keys rotate during an in-flight attempt. func h265CloudFileID(video *vidmod.VideoModel) string { if video == nil || video.ID.IsZero() { return "" } source, err := hevcpull.NormalizeSource(video.SourceURL) if err != nil { return "" } identity := fmt.Sprintf( "91porn:h265:v1\x00%s\x00%s\x00%d", video.ID.Hex(), source, video.H265FailCount, ) return laosiji.TranscodeFileID(identity) } func applyH265Result(video *vidmod.VideoModel, result laosiji.TranscodeResult) { h265URL := "" taskStatus := hevctaskmod.H265TaskStatusProcessing switch { case result.Done: h265URL = h265PlayableURL(video, result) if h265URL == "" { marked, markErr := vidmod.MarkH265Failed(video.ID, video.SourceURL, video.H265PendingAt) if markErr != nil { log.Warn("applyH265Result mark empty result failed", log.Any("videoId", video.ID), log.E(markErr)) } else if marked { _ = hevctaskmod.UpsertWithError(video.ID, video.Title, result.FileURL, result.FileID, "", hevctaskmod.H265TaskStatusFailed, "empty H265 URL in completed task") } return } updated, err := vidmod.MarkPendingH265Success(video.ID, video.SourceURL, video.H265PendingAt, h265URL) if err != nil { log.Warn("applyH265Result MarkPendingH265Success failed", log.Any("videoId", video.ID), log.E(err)) return } if !updated { // 当前视频可能已经被老司机回填 H.265,或 sourceURL 已经变化。 // 对仍处于 pending 且缺少地址的新源重新排队;已有成功地址时该操作自动跳过。 _, _ = vidmod.MarkH265Queued(video.ID, video.SourceURL, video.H265PendingAt) // 不更新 hevc_task:并发轮询的另一个实例可能已经成功落库, // 此处覆盖会把正确的 success 审计状态错误回退成 failed。 log.Info("applyH265Result ignored stale result", log.Any("videoId", video.ID)) return } clearH265VideoCache(video.ID) taskStatus = hevctaskmod.H265TaskStatusSuccess case result.Failed: marked, markErr := vidmod.MarkH265Failed(video.ID, video.SourceURL, video.H265PendingAt) if markErr != nil { log.Warn("applyH265Result MarkH265Failed failed", log.Any("videoId", video.ID), log.E(markErr)) return } if !marked { log.Info("applyH265Result ignored stale failure", log.Any("videoId", video.ID)) return } taskStatus = hevctaskmod.H265TaskStatusFailed default: // processing 结果不改变视频状态,也没有可用于跨集合原子写入的 // ModifiedCount。保留现有审计记录,避免旧轮询覆盖并发成功结果。 return } if err := hevctaskmod.UpsertWithError(video.ID, video.Title, result.FileURL, result.FileID, h265URL, taskStatus, result.ErrorMsg); err != nil { log.Warn("applyH265Result upsert task failed", log.Any("videoId", video.ID), log.E(err)) } } // h265PlayableURL 区分云端“无需转码”和真正生成了新 H265 文件的结果。 // 无需转码时源地址本身已经可直接使用,不能把我方 App 拉流 URL 错存成老司机资源路径。 func h265PlayableURL(video *vidmod.VideoModel, result laosiji.TranscodeResult) string { if result.NoNeed { if video == nil { return "" } return strings.TrimSpace(video.SourceURL) } return laosiji.MovieM3u8SourcePath(result.HevcURL) } func clearH265VideoCache(videoID primitive.ObjectID) { if skdg.Redis == nil || videoID.IsZero() { return } key := fmt.Sprintf(redisconst.VideoInfoKey(), videoID.Hex()) if _, err := skdg.Redis.Del(key); err != nil { log.Warn("clearH265VideoCache failed", log.Any("videoId", videoID), log.E(err)) } } func upsertH265TaskWithError(video *vidmod.VideoModel, domains laosiji.SystemDomainsResp, h265URL string, status hevctaskmod.H265TaskStatus, errorMsg string) { if video == nil { return } submitURL, fileID := h265TaskSubmitInfo(video, domains) if err := hevctaskmod.UpsertWithError(video.ID, video.Title, submitURL, fileID, h265URL, status, errorMsg); err != nil { log.Warn("upsertH265TaskWithError failed", log.Any("videoId", video.ID), log.E(err)) } } func h265TaskSubmitInfo(video *vidmod.VideoModel, _ laosiji.SystemDomainsResp) (string, string) { if video == nil { return "", "" } submitURL := strings.TrimSpace(hevcAppTranscodeM3u8URL(video.SourceURL, video.H265PendingAt)) if submitURL == "" { submitURL = strings.TrimSpace(video.SourceURL) } fileID := h265CloudFileID(video) // 按运维要求持久化“可直接访问”的签名 URL,便于从 hevc_task 复制出来核验。 // 代价:库中会保存有效期内可用的签名拉流地址(含 HMAC),务必收紧 DB 访问权限。 return submitURL, fileID } func h265MaxRunning() int { cfg, err := sysconfmod.GetByVCode(sysconfmod.VCodeH265MaxRunning) if err != nil { return h265DefaultMaxRunning } maxRunning, err := strconv.Atoi(cfg.Value) if err != nil || maxRunning <= 0 { return h265DefaultMaxRunning } return maxRunning } func h265HomeModuleIDs() ([]primitive.ObjectID, []string) { modules, err := moduleconfmod.GetModuleConfByType(moduleconfmod.HomePage) if err != nil { log.Warn("h265HomeModuleIDs query homepage modules failed", log.E(err)) return nil, nil } moduleIDs := make([]primitive.ObjectID, 0, len(modules)) moduleIDHexes := make([]string, 0, len(modules)) for _, module := range modules { if module.Status != 1 || module.ID.IsZero() { continue } moduleIDs = append(moduleIDs, module.ID) moduleIDHexes = append(moduleIDHexes, module.ID.Hex()) } return moduleIDs, moduleIDHexes } func appendUniqueH265VideoIDs(ids *[]primitive.ObjectID, seen map[primitive.ObjectID]struct{}, videos []*vidmod.VideoModel) { for _, video := range videos { if video == nil { continue } if _, exists := seen[video.ID]; exists { continue } seen[video.ID] = struct{}{} *ids = append(*ids, video.ID) } } func homeH265Filter(moduleID string, sortType int) primitive.M { return bson.M{ "status": vidmod.CheckPass, "newsType": vidmod.SP, "mId": moduleID, "sourceURL": bson.M{"$exists": true, "$ne": ""}, "deleteAt": bson.M{"$exists": false}, } } func homeH265Options(sortType int) *options.FindOptions { var sort bson.D switch sortType { case 2: sort = bson.D{ {Key: "liaoBaTopSort", Value: -1}, {Key: "likeCount", Value: -1}, {Key: "reviewAt", Value: -1}, } case 3: sort = bson.D{{Key: "playCount", Value: -1}, {Key: "reviewAt", Value: -1}} case 7: sort = bson.D{{Key: "collectCount", Value: -1}, {Key: "reviewAt", Value: -1}} case 9: sort = bson.D{{Key: "hot", Value: -1}, {Key: "reviewAt", Value: -1}} default: sort = bson.D{{Key: "reviewAt", Value: -1}} } return options.Find().SetLimit(h265HomeCandidateSize).SetSort(sort) }