357 lines
14 KiB
Go
357 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
accountdomain "git.ipao.vip/rogee/creator-hub/internal/account"
|
|
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
|
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
type creatorMaterialDownloader struct {
|
|
store *creator.Store
|
|
phaseAStore *accountdomain.Store
|
|
hubStore *hub.Store
|
|
}
|
|
|
|
func (downloader creatorMaterialDownloader) Download(ctx context.Context, work creator.Work, destination string) (resultErr error) {
|
|
if (work.Platform != creator.PlatformDouyin && work.Platform != creator.PlatformXiaohongshu) || downloader.store == nil || downloader.phaseAStore == nil || downloader.hubStore == nil {
|
|
return fmt.Errorf("%w: creator media gateway is unavailable", creator.ErrUnavailable)
|
|
}
|
|
accountID := work.SourceID
|
|
if work.SourceType == creator.SourceCompetitor {
|
|
var err error
|
|
accountID, err = creatorCollectionAccount(ctx, downloader.store, downloader.phaseAStore, downloader.hubStore, work.Platform)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
account, err := downloader.phaseAStore.GetAccount(ctx, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
profile, err := downloader.store.GetAccountProfile(ctx, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if account.Platform != work.Platform || profile.Platform != work.Platform || account.AuthorizationStatus != "authorized" || profile.LoginStatus != "logged_in" || account.PlatformAccountKey != profile.PlatformAccountKey {
|
|
return fmt.Errorf("%w: media account identity is not verified", creator.ErrConflict)
|
|
}
|
|
environment, err := downloader.hubStore.GetEnvironmentContextForAccount(ctx, accountID)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: media browser environment unavailable: %v", creator.ErrUnavailable, err)
|
|
}
|
|
if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 {
|
|
return fmt.Errorf("%w: media browser runtime is not running", creator.ErrUnavailable)
|
|
}
|
|
gateway, err := downloader.hubStore.GetGateway(ctx, environment.Gateway)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: media gateway unavailable: %v", creator.ErrUnavailable, err)
|
|
}
|
|
useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, downloader.hubStore, environment, "task", "creator-material-"+work.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: media runtime use unavailable: %v", creator.ErrUnavailable, err)
|
|
}
|
|
defer func() { resultErr = errors.Join(resultErr, runtimeUse.Close()) }()
|
|
if _, err := verifyCreatorPlatformIdentity(useCtx, work.Platform, gateway, environment, profile.PlatformAccountKey); err != nil {
|
|
return fmt.Errorf("%w: media browser identity verification failed: %v", creator.ErrConflict, err)
|
|
}
|
|
if work.Platform == creator.PlatformDouyin {
|
|
return (creatorGatewayBrowser{gateway: gateway, environment: environment}).Media(useCtx, work.OriginalURL, destination)
|
|
}
|
|
data, contentType, err := (xiaohongshuGatewayBrowser{gateway: gateway, environment: environment}).Media(useCtx, work.OriginalURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
contentType = strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
|
|
if !strings.HasPrefix(contentType, "video/") && contentType != "application/octet-stream" {
|
|
return fmt.Errorf("xiaohongshu media response is not a video")
|
|
}
|
|
return writeCreatorMedia(destination, data)
|
|
}
|
|
|
|
func processCreatorMaterial(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, workID string) (creator.MaterialJob, error) {
|
|
if store == nil || workID == "" || workID == "." || workID == ".." || strings.ContainsAny(workID, `/\\`) || filepath.Base(workID) != workID {
|
|
return creator.MaterialJob{}, creator.ErrInvalid
|
|
}
|
|
work, err := store.GetWork(ctx, workID)
|
|
if err != nil {
|
|
return creator.MaterialJob{}, err
|
|
}
|
|
job, err := store.GetMaterial(ctx, workID)
|
|
if err != nil {
|
|
return creator.MaterialJob{}, err
|
|
}
|
|
if !job.Selected {
|
|
return creator.MaterialJob{}, creator.ErrConflict
|
|
}
|
|
root := os.Getenv("CREATOR_MEDIA_DIR")
|
|
if root == "" {
|
|
root = "/var/lib/creatorhub/materials"
|
|
}
|
|
root, err = filepath.Abs(filepath.Clean(root))
|
|
if err != nil {
|
|
return creator.MaterialJob{}, fmt.Errorf("resolve material directory: %w", err)
|
|
}
|
|
dir := filepath.Join(root, workID)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return creator.MaterialJob{}, fmt.Errorf("create material directory: %w", err)
|
|
}
|
|
executionID, err := materialClaimToken()
|
|
if err != nil {
|
|
return creator.MaterialJob{}, err
|
|
}
|
|
executionDir := filepath.Join(dir, ".runs", executionID)
|
|
if err := os.MkdirAll(executionDir, 0o700); err != nil {
|
|
return creator.MaterialJob{}, fmt.Errorf("create material execution directory: %w", err)
|
|
}
|
|
keepExecutionDir := false
|
|
defer func() {
|
|
if !keepExecutionDir {
|
|
if cleanupErr := os.RemoveAll(executionDir); cleanupErr != nil {
|
|
logrus.WithError(cleanupErr).WithField("execution_dir", executionDir).Error("creator material execution cleanup failed")
|
|
}
|
|
}
|
|
}()
|
|
|
|
videoPath := ""
|
|
if job.DownloadStatus == "succeeded" {
|
|
videoPath, err = materialArtifactPath(root, workID, job.VideoReference)
|
|
if err != nil || !fileExists(videoPath) {
|
|
if _, setErr := store.SetMaterialStep(ctx, workID, "download", "failed", "", "下载产物不存在"); setErr != nil {
|
|
return creator.MaterialJob{}, setErr
|
|
}
|
|
job.DownloadStatus = "failed"
|
|
}
|
|
}
|
|
if job.DownloadStatus != "succeeded" {
|
|
token, tokenErr := materialClaimToken()
|
|
if tokenErr != nil {
|
|
return creator.MaterialJob{}, tokenErr
|
|
}
|
|
job, claimed, err := store.ClaimMaterialStep(ctx, workID, "download", token)
|
|
if err != nil {
|
|
return creator.MaterialJob{}, err
|
|
}
|
|
if !claimed {
|
|
return job, fmt.Errorf("%w: download step is already in progress", creator.ErrConflict)
|
|
}
|
|
videoPath = filepath.Join(executionDir, "source")
|
|
if err := (creatorMaterialDownloader{store: store, phaseAStore: phaseAStore, hubStore: hubStore}).Download(ctx, work, videoPath); err != nil {
|
|
return setMaterialFailure(ctx, store, workID, "download", token, err)
|
|
}
|
|
keepExecutionDir = true
|
|
videoReference := materialArtifactReference(workID, executionID, "source")
|
|
job, err = store.CompleteMaterialStep(ctx, workID, "download", token, "succeeded", videoReference, "")
|
|
if err != nil {
|
|
return creator.MaterialJob{}, err
|
|
}
|
|
}
|
|
|
|
audioPath := ""
|
|
if job.AudioStatus == "succeeded" {
|
|
audioPath, err = materialArtifactPath(root, workID, job.AudioReference)
|
|
if err != nil || !fileExists(audioPath) {
|
|
if _, setErr := store.SetMaterialStep(ctx, workID, "audio", "failed", "", "音频产物不存在"); setErr != nil {
|
|
return creator.MaterialJob{}, setErr
|
|
}
|
|
job.AudioStatus = "failed"
|
|
}
|
|
}
|
|
if job.AudioStatus != "succeeded" && job.AudioStatus != "no_audio" {
|
|
token, tokenErr := materialClaimToken()
|
|
if tokenErr != nil {
|
|
return creator.MaterialJob{}, tokenErr
|
|
}
|
|
job, claimed, err := store.ClaimMaterialStep(ctx, workID, "audio", token)
|
|
if err != nil {
|
|
return creator.MaterialJob{}, err
|
|
}
|
|
if !claimed {
|
|
return job, fmt.Errorf("%w: audio step is already in progress", creator.ErrConflict)
|
|
}
|
|
audioPath = filepath.Join(executionDir, "audio.wav")
|
|
hasAudio, err := creatorMaterialHasAudio(ctx, videoPath)
|
|
if err != nil {
|
|
return setMaterialFailure(ctx, store, workID, "audio", token, err)
|
|
}
|
|
if !hasAudio {
|
|
job, err = store.CompleteMaterialStep(ctx, workID, "audio", token, "no_audio", "", "视频没有音轨")
|
|
} else if err := extractCreatorAudio(ctx, videoPath, audioPath); err != nil {
|
|
return setMaterialFailure(ctx, store, workID, "audio", token, err)
|
|
} else {
|
|
keepExecutionDir = true
|
|
job, err = store.CompleteMaterialStep(ctx, workID, "audio", token, "succeeded", materialArtifactReference(workID, executionID, "audio.wav"), "")
|
|
}
|
|
if err != nil {
|
|
return creator.MaterialJob{}, err
|
|
}
|
|
}
|
|
|
|
if job.TranscriptionStatus != "succeeded" && job.TranscriptionStatus != "no_speech" {
|
|
token, tokenErr := materialClaimToken()
|
|
if tokenErr != nil {
|
|
return creator.MaterialJob{}, tokenErr
|
|
}
|
|
job, claimed, claimErr := store.ClaimMaterialStep(ctx, workID, "transcription", token)
|
|
if claimErr != nil {
|
|
return creator.MaterialJob{}, claimErr
|
|
}
|
|
if !claimed {
|
|
return job, fmt.Errorf("%w: transcription step is already in progress", creator.ErrConflict)
|
|
}
|
|
if job.AudioStatus == "no_audio" {
|
|
job, err = store.CompleteMaterialStep(ctx, workID, "transcription", token, "no_speech", "", "没有可转写的音轨")
|
|
} else {
|
|
settings, settingsErr := store.GetSettings(ctx)
|
|
if settingsErr != nil {
|
|
return setMaterialFailure(ctx, store, workID, "transcription", token, settingsErr)
|
|
}
|
|
if !settings.TranscriptionConfigured || strings.TrimSpace(settings.TranscriptionProvider) == "" || strings.TrimSpace(settings.TranscriptionModel) == "" {
|
|
return setMaterialFailure(ctx, store, workID, "transcription", token, fmt.Errorf("transcription provider is not configured"))
|
|
}
|
|
transcript, transcribeErr := transcribeCreatorAudio(ctx, audioPath, settings.TranscriptionProvider, settings.TranscriptionModel)
|
|
if transcribeErr != nil {
|
|
job, err = setMaterialFailure(ctx, store, workID, "transcription", token, transcribeErr)
|
|
} else if strings.TrimSpace(transcript) == "" {
|
|
job, err = store.CompleteMaterialStep(ctx, workID, "transcription", token, "no_speech", "", "转写未检测到语音")
|
|
} else {
|
|
transcriptPath := filepath.Join(executionDir, "transcript.txt")
|
|
if writeErr := os.WriteFile(transcriptPath, []byte(transcript), 0o600); writeErr != nil {
|
|
job, err = setMaterialFailure(ctx, store, workID, "transcription", token, writeErr)
|
|
} else {
|
|
keepExecutionDir = true
|
|
job, err = store.CompleteMaterialStep(ctx, workID, "transcription", token, "succeeded", materialArtifactReference(workID, executionID, "transcript.txt"), "")
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
return creator.MaterialJob{}, err
|
|
}
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
func materialArtifactReference(workID, executionID, name string) string {
|
|
return filepath.ToSlash(filepath.Join(workID, ".runs", executionID, name))
|
|
}
|
|
|
|
func materialArtifactPath(root, workID, reference string) (string, error) {
|
|
if reference == "" || filepath.IsAbs(reference) {
|
|
return "", creator.ErrInvalid
|
|
}
|
|
clean := filepath.Clean(filepath.FromSlash(reference))
|
|
prefix := workID + string(filepath.Separator)
|
|
if clean == "." || !strings.HasPrefix(clean, prefix) || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
|
return "", creator.ErrInvalid
|
|
}
|
|
root, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path := filepath.Join(root, clean)
|
|
relative, err := filepath.Rel(root, path)
|
|
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
return "", creator.ErrInvalid
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func materialClaimToken() (string, error) {
|
|
var data [16]byte
|
|
if _, err := rand.Read(data[:]); err != nil {
|
|
return "", fmt.Errorf("create material claim token: %w", err)
|
|
}
|
|
return hex.EncodeToString(data[:]), nil
|
|
}
|
|
|
|
func creatorMaterialHasAudio(ctx context.Context, videoPath string) (bool, error) {
|
|
if _, err := exec.LookPath("ffprobe"); err != nil {
|
|
return false, fmt.Errorf("ffprobe is unavailable: %w", err)
|
|
}
|
|
command := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=index", "-of", "csv=p=0", videoPath)
|
|
output, err := command.Output()
|
|
if err != nil {
|
|
return false, fmt.Errorf("inspect audio stream: %w", err)
|
|
}
|
|
return strings.TrimSpace(string(output)) != "", nil
|
|
}
|
|
|
|
func extractCreatorAudio(ctx context.Context, videoPath, audioPath string) error {
|
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
|
return fmt.Errorf("ffmpeg is unavailable: %w", err)
|
|
}
|
|
temporary := audioPath + ".tmp"
|
|
defer os.Remove(temporary)
|
|
command := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-v", "error", "-y", "-i", videoPath, "-vn", "-ac", "1", "-ar", "16000", "-f", "wav", temporary)
|
|
if output, err := command.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("extract audio: %w: %s", err, strings.TrimSpace(string(output)))
|
|
}
|
|
if !fileExists(temporary) {
|
|
return fmt.Errorf("extract audio produced no file")
|
|
}
|
|
if err := os.Rename(temporary, audioPath); err != nil {
|
|
return fmt.Errorf("publish audio: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateTranscriptionBinary(binary string) error {
|
|
path, err := exec.LookPath(binary)
|
|
if err != nil {
|
|
return fmt.Errorf("lookup transcription provider %q: %w", binary, err)
|
|
}
|
|
if path == "" {
|
|
return fmt.Errorf("binary path is empty")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func transcribeCreatorAudio(ctx context.Context, audioPath, provider, model string) (string, error) {
|
|
provider, model = strings.TrimSpace(provider), strings.TrimSpace(model)
|
|
if provider == "" || model == "" {
|
|
return "", fmt.Errorf("transcription provider and model are not configured")
|
|
}
|
|
if provider != "whisper" && provider != "faster-whisper" {
|
|
return "", fmt.Errorf("unsupported transcription provider %q", provider)
|
|
}
|
|
configured := strings.TrimSpace(os.Getenv("CREATOR_TRANSCRIPTION_BIN"))
|
|
if configured == "" || filepath.Base(configured) != provider {
|
|
return "", fmt.Errorf("transcription binary does not match configured provider %q", provider)
|
|
}
|
|
if err := validateTranscriptionBinary(configured); err != nil {
|
|
return "", fmt.Errorf("transcription provider is unavailable: %w", err)
|
|
}
|
|
command := exec.CommandContext(ctx, configured, audioPath, "--model", model)
|
|
output, err := command.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("transcribe audio: %w", err)
|
|
}
|
|
if len(output) > 1<<20 {
|
|
return "", fmt.Errorf("transcript exceeds size limit")
|
|
}
|
|
return string(output), nil
|
|
}
|
|
|
|
func setMaterialFailure(ctx context.Context, store *creator.Store, workID, step, token string, cause error) (creator.MaterialJob, error) {
|
|
job, err := store.CompleteMaterialStep(ctx, workID, step, token, "failed", "", cause.Error())
|
|
if err != nil {
|
|
return creator.MaterialJob{}, fmt.Errorf("record %s failure: %w", step, err)
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.Mode().IsRegular() && info.Size() > 0
|
|
}
|