package tts import ( "fmt" "os" "path/filepath" "sync" "time" "github.com/google/uuid" ) const ( defaultTTL = 10 * time.Minute cleanupInterval = 1 * time.Minute tempDirName = "tts_temp" ) // TempStore manages temporary audio files on disk with automatic TTL-based cleanup. // MIME type and other metadata are NOT stored here — they travel in the tool // result JSON through the SSE stream. type TempStore struct { dir string mu sync.RWMutex entries map[string]time.Time } // NewTempStore creates a TempStore under the given base directory. func NewTempStore(baseDir string) (*TempStore, error) { dir := filepath.Join(baseDir, tempDirName) if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("create tts temp dir: %w", err) } return &TempStore{ dir: dir, entries: make(map[string]time.Time), }, nil } // Create opens a new temporary file for writing. The caller writes audio data // into the returned file and must close it when done. func (s *TempStore) Create() (id string, f *os.File, err error) { id = uuid.New().String() path := filepath.Join(s.dir, id) f, err = os.Create(path) //nolint:gosec // Path is generated from controlled base dir + UUID. if err != nil { return "", nil, fmt.Errorf("create temp file: %w", err) } s.mu.Lock() s.entries[id] = time.Now() s.mu.Unlock() return id, f, nil } // FileSize returns the size of the temp file in bytes. func (s *TempStore) FileSize(id string) (int64, error) { path := filepath.Join(s.dir, id) info, err := os.Stat(path) if err != nil { return 0, err } return info.Size(), nil } // ReadAndDelete reads the full file contents and removes the entry. func (s *TempStore) ReadAndDelete(id string) ([]byte, error) { s.mu.RLock() _, exists := s.entries[id] s.mu.RUnlock() if !exists { return nil, fmt.Errorf("temp entry not found: %s", id) } path := filepath.Join(s.dir, id) data, err := os.ReadFile(path) //nolint:gosec // Path is generated from controlled base dir + validated entry ID. if err != nil { return nil, fmt.Errorf("read temp file: %w", err) } s.Delete(id) return data, nil } // Delete removes a temp file and its tracking entry. func (s *TempStore) Delete(id string) { s.mu.Lock() delete(s.entries, id) s.mu.Unlock() _ = os.Remove(filepath.Join(s.dir, id)) } // StartCleanup runs a background goroutine that removes expired entries. func (s *TempStore) StartCleanup(done <-chan struct{}) { ticker := time.NewTicker(cleanupInterval) defer ticker.Stop() for { select { case <-done: return case <-ticker.C: s.cleanup() } } } func (s *TempStore) cleanup() { now := time.Now() s.mu.Lock() var expired []string for id, created := range s.entries { if now.Sub(created) > defaultTTL { expired = append(expired, id) } } for _, id := range expired { delete(s.entries, id) } s.mu.Unlock() for _, id := range expired { _ = os.Remove(filepath.Join(s.dir, id)) } }