- контекст проложен от воркера и обоих входов до внешних вызовов: ffmpeg и ffprobe заводятся через exec.CommandContext, SpeechKit и Object Storage принимают ctx вместо context.Background, скачивание записи идёт запросом с контекстом. Прежде остановка сервиса не доходила до чужой работы вовсе - прерванный шаг приговора не выносит: убитый по контексту ffmpeg отдаёт «signal: killed», от настоящего отказа неотличимо ни типом, ни errors.Is, и различает их только ctx.Err(). Задача остаётся на повтор, попытку не тратит и отправителю о несуществующем сбое не сообщает; воркер не считает остановку отказом, а задача не забирается вовсе, если нас уже остановили - клиента Bot API заводит единая точка internal/adapter/telegram: токен стоит в пути каждого обращения, а http.Client кладёт адрес в *url.Error целиком. Чистка на месте употребления закрывала один вызов из пяти — теперь свой Do чистит отказ, подменённый логгер вычищает токен из строк самой библиотеки, а транспорт бота токена не получает вовсе - принятие операции распознавания защищено от отмены своим пределом: SpeechKit мог её принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз - приём по HTTP доводит запись до задачи независимо от отправителя: на контексте запроса один обрыв соединения терял полностью загруженную запись - ответ Telegram с не-2xx кодом больше не становится записью: прежде тело отказа доезжало до хранилища и умирало на ffprobe, уводя диагностику
119 lines
3.9 KiB
Go
119 lines
3.9 KiB
Go
package yandex
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
)
|
|
|
|
type YandexAudioRecognizerConfig struct {
|
|
// s3
|
|
Region string
|
|
AccessKey string
|
|
SecretKey string
|
|
BucketName string
|
|
Endpoint string
|
|
// speech kit
|
|
ApiKey string
|
|
FolderID string
|
|
}
|
|
|
|
type YandexAudioRecognizerService struct {
|
|
s3Sevice *yandexS3Service
|
|
sttService *speechKitService
|
|
}
|
|
|
|
func NewYandexAudioRecognizerService(cfg YandexAudioRecognizerConfig) (*YandexAudioRecognizerService, error) {
|
|
s3, err := newYandexS3Service(s3Config{
|
|
Region: cfg.Region,
|
|
AccessKey: cfg.AccessKey,
|
|
SecretKey: cfg.SecretKey,
|
|
BucketName: cfg.BucketName,
|
|
Endpoint: cfg.Endpoint,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stt, err := newSpeechKitService(speechKitConfig{
|
|
ApiKey: cfg.ApiKey,
|
|
FolderID: cfg.FolderID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &YandexAudioRecognizerService{
|
|
s3Sevice: s3,
|
|
sttService: stt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *YandexAudioRecognizerService) Close() error {
|
|
return s.sttService.Close()
|
|
}
|
|
|
|
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
|
|
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
|
|
// прежде, чем ответ дойдёт, и защита ничего не даст.
|
|
const startRecognitionTimeout = 10 * time.Second
|
|
|
|
func (s *YandexAudioRecognizerService) Recognize(ctx context.Context, file io.Reader, fileName string) (string, error) {
|
|
|
|
// Заливка отменяется штатно: она дорога по времени, а повтор её бесплатен —
|
|
// объект ложится под тем же ключом.
|
|
err := s.s3Sevice.uploadFile(ctx, file, fileName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
uri := s.s3Sevice.fileUrl(fileName)
|
|
|
|
// А вот принятие операции от отмены защищено. Окно короткое и дорогое:
|
|
// SpeechKit может операцию принять и начать считать деньги, а ответ до нас
|
|
// не доедет — идентификатор потеряется навсегда, и повтор оплатит ту же
|
|
// запись второй раз. Свой предел вызову оставлен, чтобы остановка не ждала
|
|
// вечно.
|
|
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
|
|
defer cancel()
|
|
|
|
opId, err := s.sttService.recognizeFileFromS3(startCtx, uri)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return opId, nil
|
|
}
|
|
|
|
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
|
|
// родителя и собственный предел по времени. Употребляется там, где обрыв стоит
|
|
// дороже ожидания: у платной операции, чей результат нельзя переспросить.
|
|
func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
|
}
|
|
|
|
func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
|
|
return s.sttService.getRecognitionText(ctx, operationID)
|
|
}
|
|
|
|
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
|
operation, err := s.sttService.checkOperationStatus(ctx, operationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !operation.Done {
|
|
return entity.NewInProgressResult(), nil
|
|
}
|
|
|
|
if opErr := operation.GetError(); opErr != nil {
|
|
errorText := fmt.Sprintf("operation failed: code %d, message: %s", opErr.Code, opErr.Message)
|
|
return entity.NewFailedResult(errorText), nil
|
|
}
|
|
|
|
return entity.NewCompletedResult(), nil
|
|
}
|