103 lines
2.2 KiB
Go
103 lines
2.2 KiB
Go
// Package transcript writes finalized captions to timestamped session logs.
|
|||
|
|
package transcript
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"tea.chunkbyte.com/kato/captioneer/src/captions"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Sink struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
file *os.File
|
||
|
|
writer *bufio.Writer
|
||
|
|
now func() time.Time
|
||
|
|
closed bool
|
||
|
|
path string
|
||
|
|
}
|
||
|
|
|
||
|
|
func New(directory string) (*Sink, error) {
|
||
|
|
return newWithClock(directory, time.Now)
|
||
|
|
}
|
||
|
|
|
||
|
|
func newWithClock(directory string, now func() time.Time) (*Sink, error) {
|
||
|
|
if strings.TrimSpace(directory) == "" {
|
||
|
|
return nil, errors.New("transcript directory cannot be empty")
|
||
|
|
}
|
||
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||
|
|
return nil, fmt.Errorf("create transcript directory: %w", err)
|
||
|
|
}
|
||
|
|
startedAt := now()
|
||
|
|
base := "captioneer-" + startedAt.Format("2006-01-02_15-04-05.000")
|
||
|
|
var file *os.File
|
||
|
|
var path string
|
||
|
|
for suffix := 0; suffix < 1000; suffix++ {
|
||
|
|
name := base + ".log"
|
||
|
|
if suffix > 0 {
|
||
|
|
name = fmt.Sprintf("%s-%d.log", base, suffix)
|
||
|
|
}
|
||
|
|
path = filepath.Join(directory, name)
|
||
|
|
candidate, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||
|
|
if errors.Is(err, os.ErrExist) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("create transcript: %w", err)
|
||
|
|
}
|
||
|
|
file = candidate
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if file == nil {
|
||
|
|
return nil, errors.New("create transcript: too many files share this timestamp")
|
||
|
|
}
|
||
|
|
return &Sink{file: file, writer: bufio.NewWriter(file), now: now, path: path}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Sink) Path() string {
|
||
|
|
return s.path
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Sink) Publish(event captions.Event) error {
|
||
|
|
if event.Kind != captions.Final {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
if s.closed {
|
||
|
|
return errors.New("transcript is closed")
|
||
|
|
}
|
||
|
|
text := strings.Join(strings.Fields(event.Text), " ")
|
||
|
|
if text == "" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
_, err := fmt.Fprintf(
|
||
|
|
s.writer,
|
||
|
|
"%s [%.2fs-%.2fs] %s\n",
|
||
|
|
s.now().Format(time.RFC3339Nano),
|
||
|
|
event.StartedAt.Seconds(),
|
||
|
|
event.EndedAt.Seconds(),
|
||
|
|
text,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return s.writer.Flush()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Sink) Close() error {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
if s.closed {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
s.closed = true
|
||
|
|
return errors.Join(s.writer.Flush(), s.file.Close())
|
||
|
|
}
|