- шаги PocketBase переехали из файла в пакет internal/adapter/repo/pocketbase/migrations, файл на шаг с именем зарегистрированного шага; туда же имена коллекций, срок сессии — в provider.go - ключ migrations в docs/.docs.json наведён на этот каталог: прежнее значение указывало на несуществующий migrations/, и шаг гейта проходил зелёным при всякой правке схемы - app.go подключает пакет шагов явным пустым импортом: пропавшая ссылка на константы унесла бы регистрацию, и хранилище поднялось бы без коллекций
272 lines
8.6 KiB
Go
272 lines
8.6 KiB
Go
package pocketbase
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
type FileRepository struct {
|
|
app core.App
|
|
}
|
|
|
|
func NewFileRepository(app core.App) *FileRepository {
|
|
return &FileRepository{app: app}
|
|
}
|
|
|
|
// 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) {
|
|
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
|
}
|
|
|
|
name := firstFileName(record)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("file %s has no content in storage", fileID)
|
|
}
|
|
|
|
work, err := newWorkFile(filepath.Ext(name))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
src, err := repo.openStored(record, name)
|
|
if err != nil {
|
|
return nil, errors.Join(err, work.Close())
|
|
}
|
|
defer src.Close()
|
|
|
|
if err := writeTo(work.path, src); err != nil {
|
|
return nil, errors.Join(err, work.Close())
|
|
}
|
|
|
|
return work, nil
|
|
}
|
|
|
|
// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание
|
|
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
|
|
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
|
|
// приватности этого не допускает. Свой суффикс хранилище допишет само.
|
|
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
|
|
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stored, err := filesystem.NewFileFromPath(work.Path())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read work file: %w", err)
|
|
}
|
|
stored.Name = name
|
|
|
|
record := core.NewRecord(collection)
|
|
record.Set("file", stored)
|
|
record.Set("location", entity.LocationLocal)
|
|
record.Set("size", stored.Size)
|
|
|
|
if err := repo.app.Save(record); err != nil {
|
|
// Отказ укладки называет имя файла — то самое, из которого строится
|
|
// ссылка на скачивание. В цепочку оно не идёт по той же причине, что и
|
|
// ключ при чтении.
|
|
return nil, errors.New("failed to store file")
|
|
}
|
|
|
|
return recordToFile(record), nil
|
|
}
|
|
|
|
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
|
|
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
record := core.NewRecord(collection)
|
|
record.Set("location", entity.LocationS3)
|
|
record.Set("object_key", objectKey)
|
|
record.Set("size", size)
|
|
|
|
if err := repo.app.Save(record); err != nil {
|
|
return nil, fmt.Errorf("failed to store remote file record: %w", err)
|
|
}
|
|
|
|
return recordToFile(record), nil
|
|
}
|
|
|
|
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
|
record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get file: %w", err)
|
|
}
|
|
return recordToFile(record), nil
|
|
}
|
|
|
|
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
|
|
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
|
}
|
|
|
|
name := firstFileName(record)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("file %s has no content in storage", fileID)
|
|
}
|
|
|
|
return repo.openStored(record, name)
|
|
}
|
|
|
|
// openStored открывает содержимое файла в хранилище потоком.
|
|
func (repo *FileRepository) openStored(record *core.Record, name string) (io.ReadCloser, error) {
|
|
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() + "/" + name)
|
|
if err != nil {
|
|
// Отказ хранилища несёт ключ файла целиком, а ключ — последняя часть
|
|
// ссылки `/api/files/...`, по которой запись скачивают. Наружу отдаётся
|
|
// идентификатор записи, и только он: цепочка `%w` уехала бы в журнал и
|
|
// стала бы там бессрочным ключом к чужому аудио.
|
|
return nil, errors.Join(
|
|
fmt.Errorf("failed to read stored file of record %s", record.Id),
|
|
fsys.Close(),
|
|
)
|
|
}
|
|
|
|
return &storedReader{reader: reader, fsys: fsys}, nil
|
|
}
|
|
|
|
// storedReader держит открытой файловую систему хранилища на всё время чтения:
|
|
// закрытая раньше времени, она обрывает поток на середине записи.
|
|
type storedReader struct {
|
|
reader io.ReadCloser
|
|
fsys io.Closer
|
|
}
|
|
|
|
func (r *storedReader) Read(p []byte) (int, error) { return r.reader.Read(p) }
|
|
|
|
func (r *storedReader) Close() error {
|
|
readerErr := r.reader.Close()
|
|
fsysErr := r.fsys.Close()
|
|
switch {
|
|
case readerErr != nil && fsysErr != nil:
|
|
return errors.New("failed to close stored file and its filesystem")
|
|
case readerErr != nil:
|
|
return errors.New("failed to close stored file")
|
|
default:
|
|
return fsysErr
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func firstFileName(record *core.Record) string {
|
|
names := record.GetStringSlice("file")
|
|
if len(names) == 0 {
|
|
return ""
|
|
}
|
|
return names[0]
|
|
}
|
|
|
|
func recordToFile(record *core.Record) *entity.File {
|
|
name := firstFileName(record)
|
|
if name == "" {
|
|
name = record.GetString("object_key")
|
|
}
|
|
|
|
return &entity.File{
|
|
Id: record.Id,
|
|
Location: record.GetString("location"),
|
|
FileName: name,
|
|
Size: int64(record.GetInt("size")),
|
|
CreatedAt: record.GetDateTime("created").Time(),
|
|
}
|
|
}
|