@@ -0,0 +1,487 @@
|
||||
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] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user