package sqlite import ( "context" "errors" "fmt" "io" "git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/ident" ) // payloadSuffix — окончание имени файла, под которым лежит сохранённый ответ // провайдера. Имя задаёт сервис, как и у копий аудио. const payloadSuffix = ".payload" type RecognitionRepository struct { db *DB store *Store } func NewRecognitionRepository(db *DB, store *Store) *RecognitionRepository { return &RecognitionRepository{db: db, store: store} } // Create заводит строку попытки **до** обращения к провайдеру. // // Порядок здесь несущий: окно между ответом провайдера и записью идентификатора // операции — то место, где теряется оплаченное. Заведённая заранее строка даёт // повторному шагу, чем проверить сделанное прежде, чем платить второй раз. func (repo *RecognitionRepository) Create(r *entity.Recognition) error { started := clock.Now() if r.Id == "" { r.Id = ident.New() } now := formatTime(started) _, err := repo.db.Writer().ExecContext(context.Background(), `INSERT INTO recognitions (id, record_id, provider, model, external_id, source_uri, started_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, r.Id, r.RecordID, r.Provider, r.Model, r.ExternalID, r.SourceURI, now, now, now, ) if err != nil { return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err) } r.StartedAt = &started return nil } // Submitted сохраняет адрес аудио и идентификатор заведённой операции. По // последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз // наружу не платит. func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error { _, err := repo.db.Writer().ExecContext(context.Background(), "UPDATE recognitions SET source_uri = ?, external_id = ?, updated_at = ? WHERE id = ?", sourceURI, externalID, formatTime(clock.Now()), id, ) if err != nil { return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err) } return nil } // Finish кладёт сохранённый ответ провайдера **третьим файлом в подкаталоге // записи** и отмечает завершение попытки. // // Файлом, а не колонкой: шаг опроса читает эту строку раз в несколько секунд, и // ответ на многочасовую запись, положенный колонкой, ехал бы в память при каждом // опросе. Хранится он потому, что результат операции у провайдера не // переспрашивается. // // Пустой ответ поверх сохранённого не кладётся — тем же доводом, что и у текста: // повторный опрос вправе вернуть пустое, и безусловная замена стёрла бы // сохранённое без возврата. func (repo *RecognitionRepository) Finish(id string, raw []byte) error { attempt, err := repo.GetByID(id) if err != nil { return err } name := id + payloadSuffix if len(raw) > 0 { if _, err := repo.store.Put(attempt.RecordID, name, bytesReader(raw)); err != nil { // Путь к сохранённому ответу наружу не идёт: отказ называет попытку // её идентификатором. return errors.Join(fmt.Errorf("failed to store provider payload of attempt %s", id), err) } } finished := formatTime(clock.Now()) if len(raw) > 0 { _, err = repo.db.Writer().ExecContext(context.Background(), "UPDATE recognitions SET payload_file = ?, finished_at = ?, updated_at = ? WHERE id = ?", name, finished, finished, id, ) } else { _, err = repo.db.Writer().ExecContext(context.Background(), "UPDATE recognitions SET finished_at = ?, updated_at = ? WHERE id = ?", finished, finished, id, ) } if err != nil { return fmt.Errorf("failed to store provider payload of attempt %s", id) } return nil } func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) { attempt := &entity.Recognition{Id: id} var startedAt, finishedAt, payloadFile nullString err := repo.db.Reader().QueryRowContext(context.Background(), `SELECT record_id, provider, model, external_id, source_uri, payload_file, started_at, finished_at FROM recognitions WHERE id = ?`, id, ).Scan( &attempt.RecordID, &attempt.Provider, &attempt.Model, &attempt.ExternalID, &attempt.SourceURI, &payloadFile, &startedAt, &finishedAt, ) if err != nil { return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err) } attempt.StartedAt = timeOf(startedAt.NullString) attempt.FinishedAt = timeOf(finishedAt.NullString) return attempt, nil } // ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ // нужен: шаг опроса читает строку попытки без него. func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) { attempt, err := repo.GetByID(id) if err != nil { return nil, err } var payloadFile string if err := repo.db.Reader().QueryRowContext(context.Background(), "SELECT payload_file FROM recognitions WHERE id = ?", id, ).Scan(&payloadFile); err != nil { return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err) } if payloadFile == "" { return nil, fmt.Errorf("recognition attempt %s has no stored payload", id) } file, err := repo.store.Open(attempt.RecordID, payloadFile) if err != nil { return nil, err } defer func() { _ = file.Close() }() raw, err := io.ReadAll(file) if err != nil { return nil, fmt.Errorf("failed to read stored payload of attempt %s", id) } return raw, nil }