package laosiji import ( "bytes" "context" "crypto/md5" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "path" "strings" "time" "91porn-server/common/hevcpull" "91porn-server/common/log" ) const ( videoTranscodingVersion = "2.0" transcodeNoNeedStatus = 4 transcodeSuccessStatus = 5 transcodeFailedStatus = -1 sourceM3u8CheckMaxBytes = 8 << 20 ) var ( errTranscodeTaskNotFound = errors.New("laosiji transcode task not found") // ErrTranscodeQueueFull 表示云端 H.265 待处理队列已经达到限制。 ErrTranscodeQueueFull = errors.New("laosiji transcode queue full") sourceM3u8HTTPClient = &http.Client{Timeout: 15 * time.Second} uploadHTTPClient = &http.Client{Timeout: 30 * time.Second} ) // SystemDomainsResp 是老司机 system/domains 接口返回的资源域名和上传配置。 type SystemDomainsResp struct { ImgFreeCDN string `json:"img_free_cdn"` MovieFreeCDN string `json:"movie_free_cdn"` MovieSourceCDN string `json:"movie_source_cdn"` UploadURL string `json:"upload_url"` MediaDir string `json:"media_dir"` UploadKey string `json:"upload_key"` UserID string `json:"user_id"` } type transcodeAPIResp struct { Status string `json:"status"` Data json.RawMessage `json:"data"` Time string `json:"time"` Error string `json:"error"` ErrorCode int `json:"errorCode"` } type transcodeTask struct { ID string `json:"id"` FileID string `json:"file_id"` FileURL string `json:"file_url"` Status int `json:"status"` Duration int `json:"duration"` Height int `json:"height"` Width int `json:"width"` TranscodeError string `json:"transcode_error"` TranscodeFile string `json:"transcode_file"` } // TranscodeQueueInfo 是云端转码队列概览。 type TranscodeQueueInfo struct { Waiting int `json:"waiting"` Done int `json:"done"` Error int `json:"error"` } // TranscodeResult 是本地异步任务使用的统一转码结果。 type TranscodeResult struct { Status int Done bool NoNeed bool Failed bool HevcURL string FileURL string FileID string ErrorMsg string } func getSystemDomainsURL() string { return strings.TrimRight(APIUrl, "/") + "/lsjapi/system/domains" } // SystemDomains 获取老司机临时上传、转码配置。 func SystemDomains(ctx context.Context) (resp SystemDomainsResp, err error) { if err = ensureConfigured(); err != nil { return resp, err } if err = moviePost(ctx, getSystemDomainsURL(), map[string]interface{}{}, &resp); err != nil { return resp, err } missing := make([]string, 0, 3) if strings.TrimSpace(resp.UploadURL) == "" { missing = append(missing, "upload_url") } if strings.TrimSpace(resp.UploadKey) == "" { missing = append(missing, "upload_key") } if strings.TrimSpace(resp.MovieSourceCDN) == "" { missing = append(missing, "movie_source_cdn") } if len(missing) > 0 { return resp, fmt.Errorf("laosiji system/domains missing: %s", strings.Join(missing, ",")) } return resp, nil } // SubmitH264ToH265ByFileURL 使用完整 file_url 异步提交转码任务。 func SubmitH264ToH265ByFileURL(ctx context.Context, fileURL string, domains SystemDomainsResp, maxRunning int) (TranscodeResult, error) { fileURL = strings.TrimSpace(fileURL) if fileURL == "" { return TranscodeResult{}, errors.New("empty file url") } return submitByTask(ctx, md5Hex(fileURL), fileURL, domains, maxRunning) } // SubmitH264ToH265Task decouples the stable cloud task ID from the temporary // signed fetch URL. This keeps polling stable across App domain or signing-key // changes while a task is pending. func SubmitH264ToH265Task(ctx context.Context, fileID, fileURL string, domains SystemDomainsResp, maxRunning int) (TranscodeResult, error) { fileID = strings.TrimSpace(fileID) fileURL = strings.TrimSpace(fileURL) if fileID == "" { return TranscodeResult{}, errors.New("empty file id") } if fileURL == "" { return TranscodeResult{}, errors.New("empty file url") } return submitByTask(ctx, fileID, fileURL, domains, maxRunning) } func submitByTask(ctx context.Context, fileID, fileURL string, domains SystemDomainsResp, maxRunning int) (TranscodeResult, error) { task, err := queryVideoTranscoding(ctx, domains, fileID) if err == nil { return transcodeTaskToResult(domains, task, fileURL, fileID), nil } if !errors.Is(err, errTranscodeTaskNotFound) { return TranscodeResult{}, err } if err = checkSourceM3u8(ctx, fileURL); err != nil { return TranscodeResult{}, err } task, err = createVideoTranscoding(ctx, domains, fileID, fileURL, maxRunning) if err != nil { return TranscodeResult{}, err } return transcodeTaskToResult(domains, task, fileURL, fileID), nil } func checkSourceM3u8(ctx context.Context, fileURL string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) if err != nil { return fmt.Errorf("build source m3u8 request: %s", hevcpull.RedactText(err.Error())) } resp, err := sourceM3u8HTTPClient.Do(req) if err != nil { return fmt.Errorf("source m3u8 request failed: %s", hevcpull.RedactText(err.Error())) } defer resp.Body.Close() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf("source m3u8 http status:%d url:%s", resp.StatusCode, hevcpull.RedactURL(fileURL)) } body, err := io.ReadAll(io.LimitReader(resp.Body, sourceM3u8CheckMaxBytes)) if err != nil { return err } if !looksLikeM3u8(body) { return fmt.Errorf("source m3u8 invalid content url:%s", hevcpull.RedactURL(fileURL)) } return nil } func looksLikeM3u8(body []byte) bool { body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF}) body = bytes.TrimLeft(body, " \t\r\n") return bytes.HasPrefix(body, []byte("#EXTM3U")) } // QueryH264ToH265 查询老司机源视频对应的 H.265 云转码结果。 func QueryH264ToH265(ctx context.Context, h264URL string) (TranscodeResult, bool, error) { domains, err := SystemDomains(ctx) if err != nil { return TranscodeResult{}, false, err } return QueryH264ToH265WithDomains(ctx, h264URL, domains) } // QueryH264ToH265WithDomains 使用已经获取的域名配置查询转码结果。 func QueryH264ToH265WithDomains(ctx context.Context, h264URL string, domains SystemDomainsResp) (TranscodeResult, bool, error) { h264URL = strings.TrimSpace(h264URL) if h264URL == "" { return TranscodeResult{}, false, errors.New("empty h264 m3u8 url") } return queryByFileURL(ctx, transcodeSourceURL(h264URL, domains.MovieSourceCDN), domains) } // QueryH264ToH265ByFileURL 与 SubmitH264ToH265ByFileURL 使用相同的完整 file_url 查询结果。 func QueryH264ToH265ByFileURL(ctx context.Context, fileURL string, domains SystemDomainsResp) (TranscodeResult, bool, error) { fileURL = strings.TrimSpace(fileURL) if fileURL == "" { return TranscodeResult{}, false, errors.New("empty file url") } return queryByTask(ctx, md5Hex(fileURL), fileURL, domains) } func queryByFileURL(ctx context.Context, fileURL string, domains SystemDomainsResp) (TranscodeResult, bool, error) { return queryByTask(ctx, md5Hex(fileURL), fileURL, domains) } // QueryH264ToH265Task queries by the stable task ID used during submission. // fileURL is diagnostic metadata only and is not sent to the cloud query API. func QueryH264ToH265Task(ctx context.Context, fileID, fileURL string, domains SystemDomainsResp) (TranscodeResult, bool, error) { fileID = strings.TrimSpace(fileID) fileURL = strings.TrimSpace(fileURL) if fileID == "" { return TranscodeResult{}, false, errors.New("empty file id") } return queryByTask(ctx, fileID, fileURL, domains) } func queryByTask(ctx context.Context, fileID, fileURL string, domains SystemDomainsResp) (TranscodeResult, bool, error) { task, err := queryVideoTranscoding(ctx, domains, fileID) if err != nil { if errors.Is(err, errTranscodeTaskNotFound) { return TranscodeResult{}, false, nil } return TranscodeResult{}, false, err } return transcodeTaskToResult(domains, task, fileURL, fileID), true, nil } func transcodeSourceURL(rawURL, movieSourceCDN string) string { rawURL = strings.TrimSpace(rawURL) movieSourceCDN = strings.TrimSpace(movieSourceCDN) if rawURL == "" || movieSourceCDN == "" { return rawURL } parsed, err := url.Parse(rawURL) if err == nil && parsed.Scheme != "" { name := path.Base(parsed.Path) if name != "" && name != "." && strings.Contains(name, ".m3u8") { return joinURLPath(movieSourceCDN, name) } return rawURL } rawURL = strings.TrimPrefix(rawURL, "/") rawURL = strings.TrimPrefix(rawURL, "laosiji/") if idx := strings.Index(rawURL, "m3m/"); idx >= 0 { rawURL = rawURL[idx+len("m3m/"):] } return joinURLPath(movieSourceCDN, rawURL) } // TranscodeSourceURL 把本地资源 path 或播放 URL 转换成云转码使用的源站 URL。 func TranscodeSourceURL(rawURL, movieSourceCDN string) string { return transcodeSourceURL(rawURL, movieSourceCDN) } func createVideoTranscoding(ctx context.Context, domains SystemDomainsResp, fileID, fileURL string, maxRunning int) (transcodeTask, error) { if q, err := getVideoTranscodingQueue(ctx, domains); err != nil { log.Warn("getVideoTranscodingQueue failed", log.E(err)) } else if maxRunning > 0 && q.Waiting >= maxRunning { return transcodeTask{}, fmt.Errorf("%w waiting:%d limit:%d", ErrTranscodeQueueFull, q.Waiting, maxRunning) } params := map[string]string{ "v": videoTranscodingVersion, "key": domains.UploadKey, "file_id": fileID, "file_url": fileURL, "ext_data": `{"project":"91porn","source":"laosiji","type":"full"}`, } if NoticeURL != "" { params["notice_url"] = NoticeURL } endpoint := joinURLPath(domains.UploadURL, "upload/videoTranscoding") return uploadAPIGet(ctx, endpoint, params) } func getVideoTranscodingQueue(ctx context.Context, domains SystemDomainsResp) (TranscodeQueueInfo, error) { endpoint := joinURLPath(domains.UploadURL, "upload/getVideoTranscodingQueueInfo") data, err := uploadAPICall(ctx, endpoint, map[string]string{ "v": videoTranscodingVersion, "key": domains.UploadKey, }) if err != nil { return TranscodeQueueInfo{}, err } var queue TranscodeQueueInfo if len(data) > 0 { if err = json.Unmarshal(data, &queue); err != nil { return TranscodeQueueInfo{}, err } } return queue, nil } func queryVideoTranscoding(ctx context.Context, domains SystemDomainsResp, fileID string) (transcodeTask, error) { endpoint := joinURLPath(domains.UploadURL, "upload/queryVideoTranscoding") return uploadAPIGet(ctx, endpoint, map[string]string{ "v": videoTranscodingVersion, "key": domains.UploadKey, "file_id": fileID, }) } func transcodeTaskToResult(domains SystemDomainsResp, task transcodeTask, fileURL, fileID string) TranscodeResult { result := TranscodeResult{Status: task.Status, FileURL: fileURL, FileID: fileID} switch { case task.Status == transcodeNoNeedStatus: result.Done = true result.NoNeed = true result.HevcURL = task.FileURL case task.Status == transcodeSuccessStatus: if transcodeFile := strings.TrimSpace(task.TranscodeFile); transcodeFile != "" { result.Done = true result.HevcURL = joinURLPath(domains.MovieSourceCDN, transcodeFile) } else { result.Failed = true result.ErrorMsg = "laosiji transcode succeeded without transcode_file" } case task.Status <= transcodeFailedStatus: result.Failed = true result.ErrorMsg = truncateToolText(task.TranscodeError, domains.UploadKey) if result.ErrorMsg == "" { result.ErrorMsg = fmt.Sprintf("laosiji transcode failed status:%d", task.Status) } } return result } func uploadAPIGet(ctx context.Context, endpoint string, params map[string]string) (transcodeTask, error) { data, err := uploadAPICall(ctx, endpoint, params) if err != nil { return transcodeTask{}, err } var task transcodeTask if len(data) > 0 { if err = json.Unmarshal(data, &task); err != nil { return transcodeTask{}, err } } return task, nil } func uploadAPICall(ctx context.Context, endpoint string, params map[string]string) (json.RawMessage, error) { parsed, err := url.Parse(endpoint) if err != nil { return nil, err } query := parsed.Query() for key, value := range params { query.Set(key, value) } parsed.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) if err != nil { return nil, err } resp, err := uploadHTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("upload api request failed: %s", truncateToolText(err.Error(), params["key"])) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } safeBody := truncateToolBody(body, params["key"]) if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return nil, fmt.Errorf("upload api http status:%d body:%s", resp.StatusCode, safeBody) } var apiResp transcodeAPIResp if err = json.Unmarshal(body, &apiResp); err != nil { return nil, err } if apiResp.Status != "y" { errMsg := truncateToolText(apiResp.Error, params["key"]) if strings.Contains(errMsg, "最多允许待处理") || strings.Contains(errMsg, "排队处理") { return nil, fmt.Errorf("%w errorCode:%d error:%s", ErrTranscodeQueueFull, apiResp.ErrorCode, errMsg) } if apiResp.ErrorCode == 2000 { return nil, fmt.Errorf("%w errorCode:%d error:%s", errTranscodeTaskNotFound, apiResp.ErrorCode, errMsg) } return nil, fmt.Errorf("upload api errorCode:%d error:%s", apiResp.ErrorCode, errMsg) } return apiResp.Data, nil } func md5Hex(value string) string { sum := md5.Sum([]byte(value)) return hex.EncodeToString(sum[:]) } // TranscodeFileID 返回源视频 URL 对应的云端幂等键。 func TranscodeFileID(fileURL string) string { fileURL = strings.TrimSpace(fileURL) if fileURL == "" { return "" } return md5Hex(fileURL) } func joinURLPath(host, uri string) string { host = strings.TrimRight(strings.TrimSpace(host), "/") uri = strings.TrimLeft(strings.TrimSpace(uri), "/") if host == "" { return uri } if uri == "" { return host } return host + "/" + uri } // MovieM3u8SourcePath 把完整 URL 或相对路径规范成本地保存的老司机资源 path。 func MovieM3u8SourcePath(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } return joinURLPath("laosiji", movieM3u8RelativePath(raw)) } // MovieM3u8OriginURL 把本地老司机资源 path 还原成源站完整 URL。 func MovieM3u8OriginURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if parsed, err := url.Parse(raw); err == nil && parsed.IsAbs() { return raw } return joinURLPath(APIUrl, movieM3u8RelativePath(raw)) } func movieM3u8RelativePath(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if parsed, err := url.Parse(raw); err == nil { if parsed.IsAbs() { raw = parsed.Path } else if parsed.Path != "" { raw = parsed.Path } } raw = strings.TrimPrefix(raw, "/") raw = strings.TrimPrefix(raw, "laosiji/") if idx := strings.Index(raw, "m3m/"); idx >= 0 { return raw[idx:] } if strings.Contains(raw, "/") { return raw } return joinURLPath("m3m", raw) } func truncateToolBody(body []byte, secrets ...string) string { return truncateToolText(string(body), secrets...) } func truncateToolText(text string, secrets ...string) string { const limit = 300 text = hevcpull.RedactText(strings.TrimSpace(text)) secrets = append(secrets, APIKey, Appid) for _, secret := range secrets { if secret = strings.TrimSpace(secret); secret != "" { text = strings.ReplaceAll(text, secret, "[REDACTED]") text = strings.ReplaceAll(text, url.QueryEscape(secret), "[REDACTED]") } } if len(text) <= limit { return text } return text[:limit] + "..." }