package main import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "git.ipao.vip/rogee/creator-hub/internal/creator" "git.ipao.vip/rogee/creator-hub/internal/hub" "git.ipao.vip/rogee/creator-hub/internal/phasea" ) type creatorMaterialDownloader struct { store *creator.Store phaseAStore *phasea.Store hubStore *hub.Store } func (downloader creatorMaterialDownloader) Download(ctx context.Context, work creator.Work, destination string) error { if work.Platform != creator.PlatformDouyin || 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 != creator.PlatformDouyin || profile.Platform != creator.PlatformDouyin || 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) } gateway, err := downloader.hubStore.GetGateway(ctx, environment.Gateway) if err != nil { return fmt.Errorf("%w: media gateway unavailable: %v", creator.ErrUnavailable, err) } browser := creatorGatewayBrowser{gateway: gateway, environment: environment} if _, err := browser.Identity(ctx, profile.PlatformAccountKey); err != nil { return fmt.Errorf("%w: media browser identity verification failed: %v", creator.ErrConflict, err) } return browser.Media(ctx, work.OriginalURL, destination) } func processCreatorMaterial(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, workID string) (creator.MaterialJob, error) { if store == nil || 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 = filepath.Clean(root) dir := filepath.Join(root, workID) if err := os.MkdirAll(dir, 0o700); err != nil { return creator.MaterialJob{}, fmt.Errorf("create material directory: %w", err) } videoPath := filepath.Join(dir, "source") videoReference := filepath.Join(workID, "source") if job.DownloadStatus != "succeeded" || !fileExists(videoPath) { if _, err := store.SetMaterialStep(ctx, workID, "download", "running", "", ""); err != nil { return creator.MaterialJob{}, err } if err := (creatorMaterialDownloader{store: store, phaseAStore: phaseAStore, hubStore: hubStore}).Download(ctx, work, videoPath); err != nil { return setMaterialFailure(ctx, store, workID, "download", err) } job, err = store.SetMaterialStep(ctx, workID, "download", "succeeded", videoReference, "") if err != nil { return creator.MaterialJob{}, err } } audioPath := filepath.Join(dir, "audio.wav") audioReference := filepath.Join(workID, "audio.wav") if job.AudioStatus != "succeeded" && job.AudioStatus != "no_audio" { if _, err := store.SetMaterialStep(ctx, workID, "audio", "running", "", ""); err != nil { return creator.MaterialJob{}, err } hasAudio, err := creatorMaterialHasAudio(ctx, videoPath) if err != nil { return setMaterialFailure(ctx, store, workID, "audio", err) } if !hasAudio { job, err = store.SetMaterialStep(ctx, workID, "audio", "no_audio", "", "视频没有音轨") if err != nil { return creator.MaterialJob{}, err } } else if err := extractCreatorAudio(ctx, videoPath, audioPath); err != nil { return setMaterialFailure(ctx, store, workID, "audio", err) } else { job, err = store.SetMaterialStep(ctx, workID, "audio", "succeeded", audioReference, "") if err != nil { return creator.MaterialJob{}, err } } } if job.TranscriptionStatus != "succeeded" && job.TranscriptionStatus != "no_speech" { if job.AudioStatus == "no_audio" { job, err = store.SetMaterialStep(ctx, workID, "transcription", "no_speech", "", "没有可转写的音轨") } else { settings, settingsErr := store.GetSettings(ctx) if settingsErr != nil { return creator.MaterialJob{}, settingsErr } if !settings.TranscriptionConfigured || strings.TrimSpace(settings.TranscriptionProvider) == "" || strings.TrimSpace(settings.TranscriptionModel) == "" { return setMaterialFailure(ctx, store, workID, "transcription", fmt.Errorf("transcription provider is not configured")) } if _, err = store.SetMaterialStep(ctx, workID, "transcription", "running", "", ""); err == nil { transcript, transcribeErr := transcribeCreatorAudio(ctx, audioPath, settings.TranscriptionModel) if transcribeErr != nil { job, err = setMaterialFailure(ctx, store, workID, "transcription", transcribeErr) } else if strings.TrimSpace(transcript) == "" { job, err = store.SetMaterialStep(ctx, workID, "transcription", "no_speech", "", "转写未检测到语音") } else { transcriptPath := filepath.Join(dir, "transcript.txt") if writeErr := os.WriteFile(transcriptPath, []byte(transcript), 0o600); writeErr != nil { job, err = setMaterialFailure(ctx, store, workID, "transcription", writeErr) } else { job, err = store.SetMaterialStep(ctx, workID, "transcription", "succeeded", filepath.Join(workID, "transcript.txt"), "") } } } } if err != nil { return creator.MaterialJob{}, err } } return job, 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", 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, model string) (string, error) { model = strings.TrimSpace(model) if model == "" { return "", fmt.Errorf("transcription model is not configured") } configured := strings.TrimSpace(os.Getenv("CREATOR_TRANSCRIPTION_BIN")) var binary string switch filepath.Base(configured) { case "whisper": binary = "whisper" case "faster-whisper": binary = "faster-whisper" default: return "", fmt.Errorf("transcription provider is not configured or unsupported") } if err := validateTranscriptionBinary(binary); err != nil { return "", fmt.Errorf("transcription provider is unavailable: %w", err) } output, err := exec.CommandContext(ctx, binary, audioPath, "--model", model).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 string, cause error) (creator.MaterialJob, error) { job, err := store.SetMaterialStep(ctx, workID, step, "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 }