- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
170 lines
9.0 KiB
Go
170 lines
9.0 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/pocketbase/pocketbase/apis"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/pocketbase/pocketbase/tools/router"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
|
)
|
|
|
|
type TranscribeHandler struct {
|
|
recordRepo contract.AudioRecordRepository
|
|
textRepo contract.TextRepository
|
|
trsService *service.TranscribeService
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewTranscribeHandler(
|
|
recordRepo contract.AudioRecordRepository,
|
|
textRepo contract.TextRepository,
|
|
trsService *service.TranscribeService,
|
|
logger *slog.Logger,
|
|
) *TranscribeHandler {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &TranscribeHandler{recordRepo: recordRepo, textRepo: textRepo, trsService: trsService, logger: logger}
|
|
}
|
|
|
|
type CreateTranscribeJobResponse struct {
|
|
JobID string `json:"job_id"`
|
|
State string `json:"status"`
|
|
}
|
|
|
|
// GetTranscribeJobResponse — ответ об одной записи.
|
|
//
|
|
// Имена полей нормативны и остались прежними: контракт HTTP API объявлен
|
|
// проектом необратимым, и переименование поля ломает внешнюю программу молча.
|
|
// Изменились **значения** поля состояния — рубеж теперь называет достигнутое, — и
|
|
// это объявленная ломка.
|
|
//
|
|
// Поле `halted` новое: отказ перестал быть состоянием, и без него остановленная
|
|
// запись выглядела бы как обычная, стоящая на своём рубеже. Машинный текст
|
|
// отказа в ответ не идёт: он принадлежит журналу владельца сервиса.
|
|
type GetTranscribeJobResponse struct {
|
|
JobID string `json:"job_id"`
|
|
State string `json:"status"`
|
|
Halted bool `json:"halted"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
TranscriptionText *string `json:"transcription_text,omitempty"`
|
|
}
|
|
|
|
// Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
|
|
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде
|
|
// сохранены — публичный контракт HTTP API объявлен необратимым.
|
|
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
|
|
api := r.Group("/api")
|
|
|
|
// Оба адреса уходят за аутентификацию. Слой предъявления стоит перед
|
|
// проверкой и действует только здесь: собственная поверхность хранилища под
|
|
// него не подпадает, часть её защищена ровно тем, что браузер заголовка сам
|
|
// не шлёт.
|
|
api.Bind(SessionFromCookie())
|
|
// Коллекция названа поимённо, а не оставлена умолчанию. Без имени проверка
|
|
// пускает всякую учётную запись хранилища, включая владельца панели, — а
|
|
// записи в коллекции пользователей у него нет, и владельцем записи он стать
|
|
// не может. Отказ такому предъявителю обязан наступить здесь, до чтения
|
|
// тела: позже пришлось бы убирать уже уложенный файл, а уборки файлов
|
|
// сервис не умеет вовсе.
|
|
api.Bind(apis.RequireAuth(migrations.UsersCollection))
|
|
|
|
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
|
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
|
|
// поэтому предел тела равен потолку самой записи.
|
|
api.POST("/audio", h.CreateTranscribeJob).Bind(apis.BodyLimit(entity.MaxRecordSize))
|
|
api.GET("/status/{id}", h.GetTranscribeJobStatus)
|
|
}
|
|
|
|
func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
|
// Получаем файл из формы
|
|
file, header, err := e.Request.FormFile("audio")
|
|
if err != nil {
|
|
return e.JSON(http.StatusBadRequest, map[string]string{"error": "No audio file provided"})
|
|
}
|
|
defer func() {
|
|
if err := file.Close(); err != nil {
|
|
h.logger.Error("Failed to close uploaded file", "error", err)
|
|
}
|
|
}()
|
|
|
|
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
|
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
|
// загруженную запись от одного обрыва соединения, а забрать результат он
|
|
// может и позже — по `GET /status/{id}`. Значения контекста (журнал запроса,
|
|
// сессия) при этом сохраняются, теряется только отмена.
|
|
ctx := context.WithoutCancel(e.Request.Context())
|
|
|
|
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
|
|
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
|
|
// чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth`
|
|
// уже есть и принадлежит коллекции пользователей.
|
|
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
|
|
if err != nil {
|
|
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
|
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
|
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create transcibe job"})
|
|
}
|
|
|
|
// Возвращаем успешный ответ
|
|
return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
|
|
JobID: record.Id,
|
|
State: record.State,
|
|
})
|
|
}
|
|
|
|
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
|
|
recordID := e.Request.PathValue("id")
|
|
|
|
// Чужая запись, запись без владельца и несуществующая отвечают одним и тем
|
|
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
|
|
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
|
|
// список заведённых записей.
|
|
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
|
|
if err != nil {
|
|
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
|
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
|
|
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
|
|
// приходит отправителю как «вашей записи нет», а владелец сервиса об
|
|
// аварии не узнаёт ниоткуда.
|
|
var notFound *contract.JobNotFoundError
|
|
if !errors.As(err, ¬Found) {
|
|
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
|
|
}
|
|
return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
|
|
}
|
|
|
|
response := GetTranscribeJobResponse{
|
|
JobID: record.Id,
|
|
State: record.State,
|
|
Halted: record.IsHalted(),
|
|
CreatedAt: record.CreatedAt,
|
|
}
|
|
|
|
// Вид текста называется **явно**: видов у записи больше одного, и отдача
|
|
// «последнего записанного» сделала бы ответ функцией порядка записи, а не
|
|
// состояния записи. В это поле уходит сырая расшифровка, и только она.
|
|
if record.TranscriptTextID != nil {
|
|
text, err := h.textRepo.GetByID(*record.TranscriptTextID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to read transcript", "error", err, "record_id", recordID)
|
|
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to read transcription"})
|
|
}
|
|
if text.Contents != "" {
|
|
contents := text.Contents
|
|
response.TranscriptionText = &contents
|
|
}
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, response)
|
|
}
|