Files
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

556 lines
19 KiB
Go

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)
}