Files
icecast-ripper/internal/recorder/recorder.go
2025-04-07 12:36:56 +03:00

220 lines
5.5 KiB
Go

package recorder
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/kemko/icecast-ripper/internal/database"
"github.com/kemko/icecast-ripper/internal/hash"
)
type Recorder struct {
tempPath string
recordingsPath string
db *database.FileStore
client *http.Client
mu sync.Mutex
isRecording bool
cancelFunc context.CancelFunc
streamName string
}
func New(tempPath, recordingsPath string, db *database.FileStore, streamName string) (*Recorder, error) {
for _, dir := range []string{tempPath, recordingsPath} {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
return &Recorder{
tempPath: tempPath,
recordingsPath: recordingsPath,
db: db,
streamName: streamName,
client: &http.Client{Timeout: 0}, // No timeout, rely on context cancellation
}, nil
}
func (r *Recorder) IsRecording() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.isRecording
}
func (r *Recorder) StartRecording(ctx context.Context, streamURL string) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.isRecording {
return errors.New("recording already in progress")
}
recordingCtx, cancel := context.WithCancel(ctx)
r.cancelFunc = cancel
r.isRecording = true
slog.Info("Starting recording", "url", streamURL)
go r.recordStream(recordingCtx, streamURL)
return nil
}
func (r *Recorder) StopRecording() {
r.mu.Lock()
defer r.mu.Unlock()
if r.isRecording && r.cancelFunc != nil {
slog.Info("Stopping current recording")
r.cancelFunc()
}
}
func (r *Recorder) recordStream(ctx context.Context, streamURL string) {
startTime := time.Now()
var tempFilePath string
defer func() {
r.mu.Lock()
r.isRecording = false
r.cancelFunc = nil
r.mu.Unlock()
slog.Info("Recording process finished")
if tempFilePath != "" {
if _, err := os.Stat(tempFilePath); err == nil {
slog.Warn("Cleaning up temporary file", "path", tempFilePath)
if err := os.Remove(tempFilePath); err != nil {
slog.Error("Failed to remove temporary file", "error", err)
}
}
}
}()
tempFile, err := os.CreateTemp(r.tempPath, "recording-*.tmp")
if err != nil {
slog.Error("Failed to create temporary file", "error", err)
return
}
tempFilePath = tempFile.Name()
slog.Debug("Created temporary file", "path", tempFilePath)
bytesWritten, err := r.downloadStream(ctx, streamURL, tempFile)
if closeErr := tempFile.Close(); closeErr != nil {
slog.Error("Failed to close temporary file", "error", closeErr)
if err == nil {
err = closeErr
}
}
// Handle context cancellation or download errors
if err != nil {
if errors.Is(err, context.Canceled) {
slog.Info("Recording stopped via cancellation")
} else {
slog.Error("Failed to download stream", "error", err)
return
}
}
// Skip empty recordings
if bytesWritten == 0 {
slog.Warn("No data written, discarding recording")
return
}
// Process successful recording
endTime := time.Now()
duration := endTime.Sub(startTime)
finalFilename := fmt.Sprintf("recording_%s.mp3", startTime.Format("20060102_150405"))
finalFilename = sanitizeFilename(finalFilename)
finalPath := filepath.Join(r.recordingsPath, finalFilename)
if err := os.Rename(tempFilePath, finalPath); err != nil {
slog.Error("Failed to move recording to final location", "error", err)
return
}
tempFilePath = "" // Prevent cleanup in defer
slog.Info("Recording saved", "path", finalPath, "size", bytesWritten, "duration", duration)
guid := hash.GenerateGUID(r.streamName, startTime, finalFilename)
if _, err = r.db.AddRecordedFile(finalFilename, guid, bytesWritten, duration, startTime); err != nil {
slog.Error("Failed to add recording to database", "error", err)
}
}
func (r *Recorder) downloadStream(ctx context.Context, streamURL string, writer io.Writer) (int64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
if err != nil {
return 0, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "icecast-ripper/1.0")
resp, err := r.client.Do(req)
if err != nil {
if errors.Is(err, context.Canceled) {
return 0, err
}
return 0, fmt.Errorf("failed to connect to stream: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
slog.Error("Failed to close response body", "error", err)
}
}()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("unexpected status code: %s", resp.Status)
}
slog.Debug("Connected to stream, downloading", "url", streamURL)
bytesWritten, err := io.Copy(writer, resp.Body)
if err != nil {
if errors.Is(err, context.Canceled) {
slog.Info("Stream download cancelled")
return bytesWritten, ctx.Err()
}
// Handle common stream disconnections gracefully
if errors.Is(err, io.ErrUnexpectedEOF) ||
strings.Contains(err.Error(), "connection reset by peer") ||
strings.Contains(err.Error(), "broken pipe") {
slog.Info("Stream disconnected normally", "bytesWritten", bytesWritten)
return bytesWritten, nil
}
return bytesWritten, fmt.Errorf("failed during stream copy: %w", err)
}
slog.Info("Stream download finished normally", "bytesWritten", bytesWritten)
return bytesWritten, nil
}
func sanitizeFilename(filename string) string {
replacer := strings.NewReplacer(
" ", "_",
"/", "-",
"\\", "-",
":", "-",
"*", "-",
"?", "-",
"\"", "'",
"<", "-",
">", "-",
"|", "-",
)
cleaned := replacer.Replace(filename)
return strings.Trim(cleaned, "_-. ")
}