хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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(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, 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, 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(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(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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user