- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
164 lines
6.3 KiB
Go
164 lines
6.3 KiB
Go
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
|
|
}
|