package pocketbase import ( "errors" "fmt" "io" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/filesystem" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/entity" ) type RecognitionRepository struct { app core.App } func NewRecognitionRepository(app core.App) *RecognitionRepository { return &RecognitionRepository{app: app} } // Create заводит строку попытки **до** обращения к провайдеру. // // Порядок здесь несущий: окно между ответом провайдера и записью идентификатора // операции — то место, где теряется оплаченное. Заведённая заранее строка даёт // повторному шагу, чем проверить сделанное прежде, чем платить второй раз. func (repo *RecognitionRepository) Create(r *entity.Recognition) error { collection, err := findCollection(repo.app, migrations.RecognitionsCollection) if err != nil { return err } started := clock.Now() record := core.NewRecord(collection) record.Set("record", r.RecordID) record.Set("provider", r.Provider) record.Set("model", r.Model) record.Set("external_id", r.ExternalID) record.Set("source_uri", r.SourceURI) record.Set("started_at", dateOrEmpty(&started)) if err := repo.app.Save(record); err != nil { return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err) } r.Id = record.Id r.StartedAt = &started return nil } // Submitted сохраняет адрес аудио и идентификатор заведённой операции. По // последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз // наружу не платит. func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error { record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id) if err != nil { return fmt.Errorf("failed to find recognition attempt %s: %w", id, err) } record.Set("source_uri", sourceURI) record.Set("external_id", externalID) if err := repo.app.Save(record); 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 { record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id) if err != nil { return fmt.Errorf("failed to find recognition attempt %s: %w", id, err) } if len(raw) > 0 { // Имя вложения задаём мы: умолчание хранилища строит его из имени // исходного файла, а имя, данное отправителем, в хранилище не попадает. payload, err := filesystem.NewFileFromBytes(raw, id+".payload") if err != nil { return fmt.Errorf("failed to prepare provider payload of attempt %s", id) } record.Set("payload", payload) } finished := clock.Now() record.Set("finished_at", dateOrEmpty(&finished)) if err := repo.app.Save(record); err != nil { // Отказ хранилища несёт имя файла вложения целиком, а оно — последняя // часть ссылки: цепочка `%w` уехала бы в журнал вместе с ним. return fmt.Errorf("failed to store provider payload of attempt %s", id) } return nil } func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) { record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id) if err != nil { return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err) } return &entity.Recognition{ Id: record.Id, RecordID: record.GetString("record"), Provider: record.GetString("provider"), Model: record.GetString("model"), ExternalID: record.GetString("external_id"), SourceURI: record.GetString("source_uri"), StartedAt: timeOrNil(record.GetDateTime("started_at")), FinishedAt: timeOrNil(record.GetDateTime("finished_at")), }, nil } // ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ // нужен: шаг опроса читает строку попытки без него. func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) { record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id) if err != nil { return nil, fmt.Errorf("failed to find recognition attempt %s: %w", id, err) } names := record.GetStringSlice("payload") if len(names) == 0 { return nil, fmt.Errorf("recognition attempt %s has no stored payload", id) } fsys, err := repo.app.NewFilesystem() if err != nil { return nil, fmt.Errorf("failed to open storage filesystem: %w", err) } reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + names[0]) if err != nil { // Отказ хранилища несёт имя вложения целиком, а имя — последняя часть // ссылки на скачивание: наружу идёт идентификатор попытки, и только он. return nil, errors.Join( fmt.Errorf("failed to read stored payload of attempt %s", id), fsys.Close(), ) } raw, readErr := io.ReadAll(reader) closeErr := errors.Join(reader.Close(), fsys.Close()) if readErr != nil { return nil, errors.Join( fmt.Errorf("failed to read stored payload of attempt %s", id), closeErr, ) } if closeErr != nil { return nil, closeErr } return raw, nil }