- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
238 lines
7.9 KiB
Go
238 lines
7.9 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
|
)
|
|
|
|
// workFile — рабочая копия файла на диске. Живёт во временном каталоге системы,
|
|
// а не в каталоге данных: последний смонтирован на сервере, и временному там не
|
|
// место.
|
|
type workFile struct {
|
|
path string
|
|
}
|
|
|
|
func (w *workFile) Path() string { return w.path }
|
|
|
|
func (w *workFile) Size() (int64, error) {
|
|
info, err := os.Stat(w.path)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to stat work file: %w", err)
|
|
}
|
|
return info.Size(), nil
|
|
}
|
|
|
|
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
|
|
// до его создания, и повторный Close тоже законен.
|
|
func (w *workFile) Close() error {
|
|
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to remove work file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FileRepository — копии записей: строка в базе и содержимое в каталоге данных.
|
|
type FileRepository struct {
|
|
db *DB
|
|
store *Store
|
|
}
|
|
|
|
func NewFileRepository(db *DB, store *Store) *FileRepository {
|
|
return &FileRepository{db: db, store: store}
|
|
}
|
|
|
|
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
|
|
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
|
|
func newWorkFile(ext string) (*workFile, error) {
|
|
f, err := os.CreateTemp("", "transcriber-*"+ext)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create work file: %w", err)
|
|
}
|
|
path := f.Name()
|
|
if err := f.Close(); err != nil {
|
|
_ = os.Remove(path)
|
|
return nil, fmt.Errorf("failed to close work file: %w", err)
|
|
}
|
|
return &workFile{path: path}, nil
|
|
}
|
|
|
|
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
|
|
return newWorkFile(ext)
|
|
}
|
|
|
|
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
|
|
work, err := newWorkFile(ext)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := writeTo(work.path, content); err != nil {
|
|
// Отказ уборки не подменяет отказ записи, но и не теряется.
|
|
return nil, errors.Join(err, work.Close())
|
|
}
|
|
|
|
return work, nil
|
|
}
|
|
|
|
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
|
file, err := repo.GetByID(fileID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
work, err := newWorkFile(filepath.Ext(file.FileName))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
src, err := repo.store.Open(file.RecordID, file.FileName)
|
|
if err != nil {
|
|
return nil, errors.Join(err, work.Close())
|
|
}
|
|
defer func() { _ = src.Close() }()
|
|
|
|
if err := writeTo(work.path, src); err != nil {
|
|
return nil, errors.Join(err, work.Close())
|
|
}
|
|
|
|
return work, nil
|
|
}
|
|
|
|
// Create кладёт рабочую копию в каталог данных и заводит строку о файле.
|
|
//
|
|
// Порядок один: строка заводится **после** того, как содержимое лежит целиком
|
|
// под рабочим именем. Обратный порядок оставлял бы в базе строку, указывающую на
|
|
// файл, которого ещё нет или который короче принятого.
|
|
//
|
|
// Отсюда и уборка: содержимое легло, а строка не сохранилась — уложенный файл
|
|
// убирается, и следа от него не остаётся. Файл, переживший свою строку, —
|
|
// штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
|
|
// принятой это мусор, на который не ссылается ничто и о котором узнать неоткуда.
|
|
//
|
|
// Владелец обязателен и лежит своей колонкой: пустой отвергает схема — колонка
|
|
// объявлена связью с учётной записью, и пустое значение ей не отвечает.
|
|
func (repo *FileRepository) Create(
|
|
recordID, name string,
|
|
work contract.WorkFile,
|
|
meta contract.FileMeta,
|
|
ownerID string,
|
|
) (*entity.File, error) {
|
|
source, err := os.Open(work.Path())
|
|
if err != nil {
|
|
// Причина сохраняется, путь снимается: он ведёт к рабочей копии чужого
|
|
// аудио, а отказ кончается в журнале.
|
|
return nil, fmt.Errorf("failed to read work file: %w", causeOf(err))
|
|
}
|
|
|
|
size, putErr := repo.store.Put(recordID, name, source)
|
|
closeErr := source.Close()
|
|
if err := errors.Join(putErr, closeErr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
file := &entity.File{
|
|
Id: ident.New(),
|
|
RecordID: recordID,
|
|
FileName: name,
|
|
Size: size,
|
|
Format: meta.Format,
|
|
DurationMs: meta.DurationMs,
|
|
CreatedAt: clock.Now(),
|
|
}
|
|
|
|
query, args := insertSQL("files", map[string]any{
|
|
"id": file.Id,
|
|
"owner_id": ownerID,
|
|
"record_id": file.RecordID,
|
|
"file_name": file.FileName,
|
|
"size_bytes": file.Size,
|
|
"format": file.Format,
|
|
"duration_ms": file.DurationMs,
|
|
"created_at": formatTime(file.CreatedAt),
|
|
})
|
|
|
|
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
|
|
// Уложенное содержимое убирается: строки о нём не будет, и ссылаться на
|
|
// него нечему. Имя файла в отказ не идёт — оно часть пути к чужому аудио.
|
|
return nil, errors.Join(
|
|
fmt.Errorf("failed to store the file row of record %s: %w", recordID, err),
|
|
repo.store.Remove(recordID, name),
|
|
)
|
|
}
|
|
|
|
return file, nil
|
|
}
|
|
|
|
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
|
file := &entity.File{}
|
|
var (
|
|
createdAt string
|
|
recordID string
|
|
fileName string
|
|
size int64
|
|
format string
|
|
durationMs int64
|
|
)
|
|
|
|
err := repo.db.Reader().QueryRowContext(context.Background(),
|
|
`SELECT record_id, file_name, size_bytes, format, duration_ms, created_at
|
|
FROM files WHERE id = ?`, id,
|
|
).Scan(&recordID, &fileName, &size, &format, &durationMs, &createdAt)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("file %s is not found", id)
|
|
}
|
|
return nil, fmt.Errorf("failed to get file %s: %w", id, err)
|
|
}
|
|
|
|
file.Id = id
|
|
file.RecordID = recordID
|
|
file.FileName = fileName
|
|
file.Size = size
|
|
file.Format = format
|
|
file.DurationMs = durationMs
|
|
file.CreatedAt = requiredTimeOf(createdAt)
|
|
|
|
return file, nil
|
|
}
|
|
|
|
// Open отдаёт содержимое хранимой копии потоком с перемоткой: отдача по
|
|
// диапазону читает кусок, а не файл целиком.
|
|
func (repo *FileRepository) Open(fileID string) (io.ReadSeekCloser, error) {
|
|
file, err := repo.GetByID(fileID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return repo.store.Open(file.RecordID, file.FileName)
|
|
}
|
|
|
|
// writeTo переливает содержимое в файл потоком. В память запись целиком не
|
|
// читается: расчётный потолок — шесть часов.
|
|
func writeTo(path string, content io.Reader) error {
|
|
dst, err := os.Create(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open work file: %w", err)
|
|
}
|
|
|
|
if _, err := io.Copy(dst, content); err != nil {
|
|
_ = dst.Close()
|
|
return fmt.Errorf("failed to write work file: %w", err)
|
|
}
|
|
|
|
if err := dst.Close(); err != nil {
|
|
return fmt.Errorf("failed to close work file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|