diff --git a/cmd/bot.go b/cmd/bot.go index 32c14fc..be7cd8f 100644 --- a/cmd/bot.go +++ b/cmd/bot.go @@ -114,7 +114,7 @@ func (c *BotCommand) Execute(ctx context.Context, f *flag.FlagSet, args ...inter var asynqClient *asynq.Client var asynqInspector *asynq.Inspector - if config.Encoding.Enabled { + if config.Encoding.Enabled || config.Transcription.Enabled { redisAddr := fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port) asynqClient = asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr}) defer asynqClient.Close() @@ -217,7 +217,7 @@ func (c *BotCommand) Execute(ctx context.Context, f *flag.FlagSet, args ...inter logger.Info("CreateChannels OK") // エンコード結果取得タスク - if config.Encoding.Enabled { + if config.Encoding.Enabled || config.Transcription.Enabled { scheduler.Every("1m").Do(func() { err := usecase.CheckCompletedTask(ctx) if err != nil { diff --git a/cmd/worker.go b/cmd/worker.go index ff8bece..1fb24a7 100644 --- a/cmd/worker.go +++ b/cmd/worker.go @@ -10,6 +10,7 @@ import ( "github.com/hibiken/asynq" "github.com/jinzhu/configor" "github.com/kounoike/dtv-discord-go/config" + "github.com/kounoike/dtv-discord-go/gpt" "github.com/kounoike/dtv-discord-go/tasks" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -76,9 +77,12 @@ func (c *WorkerCommand) Execute(ctx context.Context, f *flag.FlagSet, args ...in }, ) + gpt := gpt.NewGPTClient(config.OpenAI.Enabled, config.OpenAI.Token, logger) + mux := asynq.NewServeMux() - tmpl := template.Must(template.New("output-name-tmpl").Parse(config.Encoding.EncodeCommandTemplate)) + tmpl := template.Must(template.New("encode-command-tmpl").Parse(config.Encoding.EncodeCommandTemplate)) mux.Handle(tasks.TypeProgramEncode, tasks.NewProgramEncoder(logger, tmpl, config.Recording.BasePath, config.Encoding.BasePath)) + mux.Handle(tasks.TypeProgramTranscription, tasks.NewProgramTranscriber(logger, gpt, tmpl, config.Recording.BasePath, config.Transcription.BasePath)) mux.HandleFunc(tasks.TypeHello, tasks.HelloTask) logger.Debug("Starting worker server") diff --git a/config/config.go b/config/config.go index d5d7ee6..5783cdd 100644 --- a/config/config.go +++ b/config/config.go @@ -31,6 +31,11 @@ type Config struct { OutputPathTemplate string `required:"true" env:"ENCODING_OUTPUT_PATH_TEMPLATE"` EncodeCommandTemplate string `required:"true" env:"ENCODING_COMMAND"` } + Transcription struct { + Enabled bool `required:"true" env:"TRANSCRIPTION_ENABLED"` + BasePath string `required:"true" env:"TRANSCRIPTION_BASE_PATH"` + OutputPathTemplate string `required:"true" env:"TRANSCRIPTION_OUTPUT_PATH_TEMPLATE"` + } Match struct { KanaMatch bool `default:"true" env:"KANA_MATCH"` FuzzyMatch bool `default:"true" env:"FUZZY_MATCH"` diff --git a/discord/emoji.go b/discord/emoji.go index c079c7c..1258ab4 100644 --- a/discord/emoji.go +++ b/discord/emoji.go @@ -1,10 +1,11 @@ package discord const ( - RecordingReactionEmoji = "🔴" - RecordedReactionEmoji = "📼" - EncodedReactionEmoji = "🗜️" - OkReactionEmoji = "🆗" - NotifyReactionEmoji = "👀" - AutoSearchReactionEmoji = "🔍" + RecordingReactionEmoji = "🔴" + RecordedReactionEmoji = "📼" + EncodedReactionEmoji = "🗜️" + OkReactionEmoji = "🆗" + NotifyReactionEmoji = "👀" + AutoSearchReactionEmoji = "🔍" + TranscriptionReactionEmoji = "📝" ) diff --git a/docker-compose/config.yml.example b/docker-compose/config.yml.example index 7819fe2..7f44728 100644 --- a/docker-compose/config.yml.example +++ b/docker-compose/config.yml.example @@ -21,6 +21,10 @@ encoding: basepath: "/encoded" outputpathtemplate: "{{.Program.Name | fold}}-{{.Program.StartTime.Format \"20060102-1504\"}}-{{.Service.Name | fold}}.mp4" encodecommandtemplate: "ffmpeg -i {{.InputPath}} {{.OutputPath}} -y" +transcription: + enabled: true + basepath: "/transcribed" + outputpathtemplate: "{{.Title | fold}} #{{.Episode}} [{{.Subtitle}}] {{.Program.StartTime.Format \"20060102-1504\"}} {{.Service.Name | fold}}.txt" match: kanamatch: true fuzzymatch: true diff --git a/docker-compose/docker-compose.yml.example b/docker-compose/docker-compose.yml.example index bf17bc4..0ce9c0d 100644 --- a/docker-compose/docker-compose.yml.example +++ b/docker-compose/docker-compose.yml.example @@ -54,6 +54,7 @@ services: - ./config.yml:/config.yml:ro - ./mirakc/recorded:/recorded:rw - ./mirakc/encoded:/encoded:rw + - ./mirakc/transcribed:/transcribed:rw environment: TZ: Asia/Tokyo links: diff --git a/dtv/check_completed_task.go b/dtv/check_completed_task.go index c0c1053..140e4f9 100644 --- a/dtv/check_completed_task.go +++ b/dtv/check_completed_task.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" + "github.com/hibiken/asynq" "github.com/kounoike/dtv-discord-go/db" "github.com/kounoike/dtv-discord-go/discord" "github.com/kounoike/dtv-discord-go/tasks" @@ -13,8 +14,85 @@ import ( "go.uber.org/zap" ) +func (dtv *DTVUsecase) onProgramEncoded(ctx context.Context, taskInfo *asynq.TaskInfo) error { + _, err := dtv.queries.GetEncodeTaskByTaskID(ctx, taskInfo.ID) + if errors.Cause(err) != sql.ErrNoRows { + return err + } + var payload tasks.ProgramEncodePayload + err = json.Unmarshal(taskInfo.Payload, &payload) + if err != nil { + dtv.logger.Warn("task payload json.Unmarshal error", zap.Error(err)) + return err + } + err = dtv.queries.InsertEncodeTask(ctx, db.InsertEncodeTaskParams{TaskID: taskInfo.ID, Status: "success"}) + if err != nil { + dtv.logger.Warn("failed to InsertEncodeTask", zap.Error(err)) + return err + } + _, err = dtv.discord.SendMessage(discord.InformationCategory, discord.RecordingChannel, fmt.Sprintf("**エンコード完了** `%s`のエンコードが完了しました", payload.OutputPath)) + if err != nil { + dtv.logger.Warn("failed to SendMessage", zap.Error(err)) + return err + } + programMessage, err := dtv.queries.GetProgramMessageByProgramID(ctx, payload.ProgramId) + if errors.Cause(err) == sql.ErrNoRows { + dtv.logger.Warn("failed to GetProgramMessageByProgramID", zap.Error(err)) + return err + } + if err != nil { + dtv.logger.Warn("failed to GetProgramMessageByProgramID", zap.Error(err)) + return err + } + + err = dtv.discord.MessageReactionAdd(programMessage.ChannelID, programMessage.MessageID, discord.EncodedReactionEmoji) + if err != nil { + dtv.logger.Warn("failed to MessageReactionAdd", zap.Error(err)) + return err + } + return nil +} + +func (dtv *DTVUsecase) onProgramTranscribed(ctx context.Context, taskInfo *asynq.TaskInfo) error { + // _, err := dtv.queries.GetEncodeTaskByTaskID(ctx, taskInfo.ID) + // if errors.Cause(err) != sql.ErrNoRows { + // return err + // } + var payload tasks.ProgramTranscriptionPayload + err := json.Unmarshal(taskInfo.Payload, &payload) + if err != nil { + dtv.logger.Warn("task payload json.Unmarshal error", zap.Error(err)) + return err + } + // err = dtv.queries.InsertEncodeTask(ctx, db.InsertEncodeTaskParams{TaskID: taskInfo.ID, Status: "success"}) + // if err != nil { + // dtv.logger.Warn("failed to InsertEncodeTask", zap.Error(err)) + // return err + // } + _, err = dtv.discord.SendMessage(discord.InformationCategory, discord.RecordingChannel, fmt.Sprintf("**文字起こし完了** `%s`の文字起こしが完了しました", payload.OutputPath)) + if err != nil { + dtv.logger.Warn("failed to SendMessage", zap.Error(err)) + return err + } + programMessage, err := dtv.queries.GetProgramMessageByProgramID(ctx, payload.ProgramId) + if errors.Cause(err) == sql.ErrNoRows { + dtv.logger.Warn("failed to GetProgramMessageByProgramID", zap.Error(err)) + return err + } + if err != nil { + dtv.logger.Warn("failed to GetProgramMessageByProgramID", zap.Error(err)) + return err + } + + err = dtv.discord.MessageReactionAdd(programMessage.ChannelID, programMessage.MessageID, discord.TranscriptionReactionEmoji) + if err != nil { + dtv.logger.Warn("failed to MessageReactionAdd", zap.Error(err)) + return err + } + return nil +} + func (dtv *DTVUsecase) CheckCompletedTask(ctx context.Context) error { - dtv.logger.Debug("Start CheckCompletedTask") if dtv.inspector == nil { return nil } @@ -23,41 +101,13 @@ func (dtv *DTVUsecase) CheckCompletedTask(ctx context.Context) error { return err } for _, taskInfo := range taskInfoList { - if taskInfo.Type != tasks.TypeProgramEncode { + switch taskInfo.Type { + case tasks.TypeHello: continue - } - _, err := dtv.queries.GetEncodeTaskByTaskID(ctx, taskInfo.ID) - if errors.Cause(err) != sql.ErrNoRows { - continue - } - var payload tasks.ProgramEncodePayload - err = json.Unmarshal(taskInfo.Payload, &payload) - if err != nil { - dtv.logger.Warn("task payload json.Unmarshal error", zap.Error(err)) - continue - } - err = dtv.queries.InsertEncodeTask(ctx, db.InsertEncodeTaskParams{TaskID: taskInfo.ID, Status: "success"}) - if err != nil { - dtv.logger.Warn("failed to InsertEncodeTask", zap.Error(err)) - continue - } - _, err = dtv.discord.SendMessage(discord.InformationCategory, discord.RecordingChannel, fmt.Sprintf("**エンコード完了** `%s`のエンコードが完了しました", payload.OutputPath)) - if err != nil { - dtv.logger.Warn("failed to SendMessage", zap.Error(err)) - continue - } - programMessage, err := dtv.queries.GetProgramMessageByProgramID(ctx, payload.ProgramId) - if errors.Cause(err) == sql.ErrNoRows { - dtv.logger.Warn("failed to GetProgramMessageByProgramID", zap.Error(err)) - continue - } - if err != nil { - dtv.logger.Warn("failed to GetProgramMessageByProgramID", zap.Error(err)) - } - - err = dtv.discord.MessageReactionAdd(programMessage.ChannelID, programMessage.MessageID, discord.EncodedReactionEmoji) - if err != nil { - dtv.logger.Warn("failed to MessageReactionAdd", zap.Error(err)) + case tasks.TypeProgramEncode: + _ = dtv.onProgramEncoded(ctx, taskInfo) + case tasks.TypeProgramTranscription: + _ = dtv.onProgramTranscribed(ctx, taskInfo) } } return nil diff --git a/dtv/check_failed_task.go b/dtv/check_failed_task.go index 5f38548..33bc534 100644 --- a/dtv/check_failed_task.go +++ b/dtv/check_failed_task.go @@ -14,7 +14,6 @@ import ( ) func (dtv *DTVUsecase) CheckFailedTask(ctx context.Context) error { - dtv.logger.Debug("Start CheckFailedTask") if dtv.inspector == nil { return nil } diff --git a/dtv/dtv_usecase.go b/dtv/dtv_usecase.go index d7853ad..6b04345 100644 --- a/dtv/dtv_usecase.go +++ b/dtv/dtv_usecase.go @@ -16,19 +16,22 @@ import ( ) type DTVUsecase struct { - asynq *asynq.Client - inspector *asynq.Inspector - discord *discord_client.DiscordClient - mirakc *mirakc_client.MirakcClient - scheduler *gocron.Scheduler - queries *db.Queries - logger *zap.Logger - contentPathTmpl *template.Template - outputPathTmpl *template.Template - autoSearchChannel *discordgo.Channel - kanaMatch bool - fuzzyMatch bool - gpt *gpt.GPTClient + asynq *asynq.Client + inspector *asynq.Inspector + discord *discord_client.DiscordClient + mirakc *mirakc_client.MirakcClient + scheduler *gocron.Scheduler + queries *db.Queries + logger *zap.Logger + contentPathTmpl *template.Template + encodingOutputPathTmpl *template.Template + transcriptionOutputPathTmpl *template.Template + autoSearchChannel *discordgo.Channel + gpt *gpt.GPTClient + kanaMatch bool + fuzzyMatch bool + encodingEnabled bool + transcriptionEnabled bool } func fold(str string) string { @@ -55,22 +58,29 @@ func NewDTVUsecase( if err != nil { return nil, err } - outputTmpl, err := template.New("output-path").Funcs(funcMap).Parse(cfg.Encoding.OutputPathTemplate) + encodingOutputTmpl, err := template.New("encoding-output-path").Funcs(funcMap).Parse(cfg.Encoding.OutputPathTemplate) + if err != nil { + return nil, err + } + transcriptionOutputTmpl, err := template.New("transcription-output-path").Funcs(funcMap).Parse(cfg.Transcription.OutputPathTemplate) if err != nil { return nil, err } return &DTVUsecase{ - asynq: asynqClient, - inspector: inspector, - discord: discordClient, - mirakc: mirakcClient, - scheduler: scheduler, - queries: queries, - logger: logger, - contentPathTmpl: contentTmpl, - outputPathTmpl: outputTmpl, - kanaMatch: kanaMatch, - fuzzyMatch: fuzzyMatch, - gpt: gpt, + asynq: asynqClient, + inspector: inspector, + discord: discordClient, + mirakc: mirakcClient, + scheduler: scheduler, + queries: queries, + logger: logger, + gpt: gpt, + contentPathTmpl: contentTmpl, + encodingOutputPathTmpl: encodingOutputTmpl, + transcriptionOutputPathTmpl: transcriptionOutputTmpl, + kanaMatch: kanaMatch, + fuzzyMatch: fuzzyMatch, + encodingEnabled: cfg.Encoding.Enabled, + transcriptionEnabled: cfg.Transcription.Enabled, }, nil } diff --git a/dtv/get_content_path.go b/dtv/get_content_path.go index 2276378..f5c0a6b 100644 --- a/dtv/get_content_path.go +++ b/dtv/get_content_path.go @@ -31,21 +31,21 @@ func (dtv *DTVUsecase) getContentPath(ctx context.Context, program db.Program, s return contentPath, nil } -func (dtv *DTVUsecase) getOutputPath(ctx context.Context, program db.Program, service db.Service) (string, error) { +func (dtv *DTVUsecase) getEncodingOutputPath(ctx context.Context, program db.Program, service db.Service, pathData *template.PathTemplateData) (string, error) { var b bytes.Buffer - data := template.PathTemplateData{} - - _ = dtv.gpt.ParseTitle(ctx, program.Name, &data) - data.Program = template.PathProgram{ - Name: program.Name, - StartTime: program.StartTime(), - } - data.Service = template.PathService{ - Name: service.Name, + err := dtv.encodingOutputPathTmpl.Execute(&b, pathData) + if err != nil { + return "", err } + outputPath := b.String() - err := dtv.outputPathTmpl.Execute(&b, data) + return outputPath, nil +} + +func (dtv *DTVUsecase) getTranscriptionOutputPath(ctx context.Context, program db.Program, service db.Service, pathData *template.PathTemplateData) (string, error) { + var b bytes.Buffer + err := dtv.transcriptionOutputPathTmpl.Execute(&b, pathData) if err != nil { return "", err } diff --git a/dtv/on_recording_stopped.go b/dtv/on_recording_stopped.go index 065361e..81432ed 100644 --- a/dtv/on_recording_stopped.go +++ b/dtv/on_recording_stopped.go @@ -41,26 +41,58 @@ func (dtv *DTVUsecase) OnRecordingStopped(ctx context.Context, programId int64) } if dtv.asynq != nil { - // NOTE: encoding.enabled = trueのとき - outputPath, err := dtv.getOutputPath(ctx, program, service) - if err != nil { - return err + // NOTE: encoding.enabled = true or transcription.enabled = trueのとき + pathData := template.PathTemplateData{} + + _ = dtv.gpt.ParseTitle(ctx, program.Name, &pathData) + + pathData.Program = template.PathProgram{ + Name: program.Name, + StartTime: program.StartTime(), + } + pathData.Service = template.PathService{ + Name: service.Name, } + if dtv.encodingEnabled { + outputPath, err := dtv.getEncodingOutputPath(ctx, program, service, &pathData) + if err != nil { + return err + } + task, err := tasks.NewProgramEncodeTask(programId, contentPath, outputPath) + if err != nil { + // NOTE: 多分JSONMarshalの失敗なので無視する + dtv.logger.Warn("NewProgramEncodeTask failed", zap.Error(err)) + return nil + } - task, err := tasks.NewProgramEncodeTask(programId, contentPath, outputPath) - if err != nil { - // NOTE: 多分JSONMarshalの失敗なので無視する - dtv.logger.Warn("NewProgramEncodeTask failed", zap.Error(err)) - return nil + info, err := dtv.asynq.Enqueue(task) + if err != nil { + // NOTE: エンキュー失敗は無視する + dtv.logger.Warn("task enqueue failed", zap.Error(err), zap.Int64("programId", programId), zap.String("contentPath", contentPath)) + return nil + } + dtv.logger.Debug("task enqueue success", zap.String("Type", info.Type)) } + if dtv.transcriptionEnabled { + outputPath, err := dtv.getTranscriptionOutputPath(ctx, program, service, &pathData) + if err != nil { + return err + } + task, err := tasks.NewProgramTranscriptionTask(programId, contentPath, outputPath) + if err != nil { + // NOTE: 多分JSONMarshalの失敗なので無視する + dtv.logger.Warn("NewProgramTranscriptionTask failed", zap.Error(err)) + return nil + } - info, err := dtv.asynq.Enqueue(task) - if err != nil { - // NOTE: エンキュー失敗は無視する - dtv.logger.Warn("task enqueue failed", zap.Error(err), zap.Int64("programId", programId), zap.String("contentPath", contentPath)) - return nil + info, err := dtv.asynq.Enqueue(task) + if err != nil { + // NOTE: エンキュー失敗は無視する + dtv.logger.Warn("task enqueue failed", zap.Error(err), zap.Int64("programId", programId), zap.String("contentPath", contentPath)) + return nil + } + dtv.logger.Debug("task enqueue success", zap.String("Type", info.Type)) } - dtv.logger.Debug("task enqueue success", zap.String("Type", info.Type)) } return nil diff --git a/gpt/gpt_client.go b/gpt/gpt_client.go index 6e82c24..5ac0de7 100644 --- a/gpt/gpt_client.go +++ b/gpt/gpt_client.go @@ -66,3 +66,21 @@ func (c *GPTClient) ParseTitle(ctx context.Context, title string, pathTemplateDa return nil } + +func (c *GPTClient) TranscribeText(ctx context.Context, audioFilePath string) (string, error) { + if !c.enabled { + return "", nil + } + req := openai.AudioRequest{ + Model: openai.Whisper1, + Language: "ja", + FilePath: audioFilePath, + } + client := openai.NewClient(c.token) + resp, err := client.CreateTranscription(ctx, req) + if err != nil { + return "", err + } + + return resp.Text, nil +} diff --git a/main.go b/main.go index 1a86bca..52044be 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( ) var ( - version = "develop" + version = "0.0.0-develop" ) func main() { diff --git a/tasks/transcription_task.go b/tasks/transcription_task.go new file mode 100644 index 0000000..c38a8d1 --- /dev/null +++ b/tasks/transcription_task.go @@ -0,0 +1,115 @@ +package tasks + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path" + "text/template" + "time" + + "github.com/hibiken/asynq" + "github.com/kounoike/dtv-discord-go/gpt" + "github.com/mattn/go-shellwords" + "go.uber.org/zap" +) + +const TypeProgramTranscription = "program:transcription" + +type ProgramTranscriptionPayload struct { + ProgramId int64 `json:"programId"` + ContentPath string `json:"contentPath"` + OutputPath string `json:"outputPath"` +} + +func NewProgramTranscriptionTask(programId int64, contentPath string, outputPath string) (*asynq.Task, error) { + payload, err := json.Marshal(ProgramTranscriptionPayload{ProgramId: programId, ContentPath: contentPath, OutputPath: outputPath}) + if err != nil { + return nil, err + } + return asynq.NewTask(TypeProgramTranscription, payload, asynq.MaxRetry(10), asynq.Timeout(20*time.Hour), asynq.Retention(30*time.Minute)), nil +} + +type ProgramTranscriber struct { + logger *zap.Logger + recordedBasePath string + transcribedBasePath string + gpt *gpt.GPTClient +} + +func NewProgramTranscriber(logger *zap.Logger, gpt *gpt.GPTClient, encodeCommandTmpl *template.Template, recordedBasePath string, transcribedBasePath string) *ProgramTranscriber { + return &ProgramTranscriber{ + logger: logger, + gpt: gpt, + recordedBasePath: recordedBasePath, + transcribedBasePath: transcribedBasePath, + } +} + +func (e *ProgramTranscriber) ProcessTask(ctx context.Context, t *asynq.Task) error { + var p ProgramTranscriptionPayload + err := json.Unmarshal(t.Payload(), &p) + if err != nil { + return fmt.Errorf("json.Unmarshal failed: %v: %w", err, asynq.SkipRetry) + } + e.logger.Debug("Running ProcessTask", zap.Int64("programId", p.ProgramId), zap.String("contentPath", p.ContentPath), zap.String("outputPath", p.OutputPath)) + + if p.ContentPath == "" || p.OutputPath == "" { + e.logger.Error("empty ContentPath or OutputPath") + return nil + } + + tmpFile := fmt.Sprintf("/tmp/%d.m4a", p.ProgramId) + commandLine := fmt.Sprintf(`ffmpeg -i "%s" -vn -ac 1 -ar 16000 -ab 32k "%s" -y`, path.Join(e.recordedBasePath, p.ContentPath), tmpFile) + + e.logger.Info("Running split audio command", zap.String("command", commandLine)) + + args, err := shellwords.Parse(commandLine) + if err != nil { + return fmt.Errorf("split audio command shell parse error: %v: %w", err, asynq.SkipRetry) + } + + var cmd *exec.Cmd + switch len(args) { + case 0: + return fmt.Errorf("split audio command is empty %w", asynq.SkipRetry) + case 1: + cmd = exec.Command(args[0]) + default: + cmd = exec.Command(args[0], args[1:]...) + } + out, err := cmd.CombinedOutput() + if err != nil { + e.logger.Error("split audio command execution error", zap.Error(err), zap.ByteString("output", out)) + return err + } + e.logger.Debug("split audio command succeeded") + + text, err := e.gpt.TranscribeText(ctx, tmpFile) + os.Remove(tmpFile) + if err != nil { + e.logger.Error("TranscribeText error", zap.Error(err)) + return err + } + + file, err := os.Create(path.Join(e.transcribedBasePath, p.OutputPath)) + if err != nil { + e.logger.Error("File Create error", zap.Error(err)) + return err + } + + _, err = file.WriteString(text) + if err != nil { + e.logger.Error("write transcribed text to file error", zap.Error(err)) + return err + } + err = file.Close() + if err != nil { + e.logger.Error("close error", zap.Error(err)) + return err + } + + return nil +} diff --git a/template/recording_started_message.go b/template/recording_started_message.go index bca3e06..ef1c6df 100644 --- a/template/recording_started_message.go +++ b/template/recording_started_message.go @@ -15,8 +15,8 @@ type recordingStartedMessageTemplateArgs struct { const ( recordingStartedMessageTemplateString = `**録画開始**:{{ .Program.Name }} -{{ .Service.Name }} {{ .Program.StartAt |toTimeStr }}~{{ .Program.Duration | toDurationStr }} -保存先:` + "`" + `{{ .ContentPath }}` + "`" + {{ .Service.Name }} {{ .Program.StartAt |toTimeStr }}~{{ .Program.Duration | toDurationStr }} + 保存先:` + "`" + `{{ .ContentPath }}` + "`" ) func GetRecordingStartedMessage(program db.Program, service db.Service, contentPath string) (string, error) {