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 }