Files
icecast-ripper/internal/rss/rss.go
Dmitry Andreev 06b19c7cb4
Some checks failed
Docker / build (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
golangci-lint / lint (push) Has been cancelled
Round recording duration to the nearest second in RSS feed items (#8)
2025-04-13 23:31:51 +03:00

183 lines
4.9 KiB
Go

package rss
import (
"fmt"
"io/fs"
"log/slog"
"math"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/gorilla/feeds"
"github.com/kemko/icecast-ripper/internal/config"
"github.com/kemko/icecast-ripper/internal/hash"
"github.com/kemko/icecast-ripper/internal/mp3util"
)
// RecordingInfo contains metadata about a recording
type RecordingInfo struct {
Filename string
Hash string
FileSize int64
Duration time.Duration
RecordedAt time.Time
}
// Generator creates RSS feeds for recorded streams
type Generator struct {
baseURL string
recordingsPath string
feedTitle string
feedDesc string
streamName string
}
// New creates a new RSS Generator instance
func New(cfg *config.Config, title, description, streamName string) *Generator {
baseURL := cfg.PublicURL
// Ensure base URL ends with a slash
if !strings.HasSuffix(baseURL, "/") {
baseURL += "/"
}
return &Generator{
baseURL: baseURL,
recordingsPath: cfg.RecordingsPath,
feedTitle: title,
feedDesc: description,
streamName: streamName,
}
}
// Pattern to extract timestamp from recording filename (stream.somesite.com_20240907_195622.mp3)
var recordingPattern = regexp.MustCompile(`([^_]+)_(\d{8}_\d{6})\.mp3$`)
// GenerateFeed produces the RSS feed XML
func (g *Generator) GenerateFeed(maxItems int) ([]byte, error) {
recordings, err := g.scanRecordings(maxItems)
if err != nil {
return nil, fmt.Errorf("failed to scan recordings: %w", err)
}
feed := &feeds.Feed{
Title: g.feedTitle,
Link: &feeds.Link{Href: g.baseURL},
Description: g.feedDesc,
Created: time.Now(),
}
feed.Items = g.createFeedItems(recordings)
rssFeed, err := feed.ToRss()
if err != nil {
return nil, fmt.Errorf("failed to generate RSS feed: %w", err)
}
slog.Debug("RSS feed generated", "itemCount", len(feed.Items))
return []byte(rssFeed), nil
}
// createFeedItems converts recording info to RSS feed items
func (g *Generator) createFeedItems(recordings []RecordingInfo) []*feeds.Item {
items := make([]*feeds.Item, 0, len(recordings))
baseURL := strings.TrimSuffix(g.baseURL, "/")
for _, rec := range recordings {
fileURL := fmt.Sprintf("%s/recordings/%s", baseURL, rec.Filename)
// Round duration to the nearest second
roundedDuration := time.Duration(math.Round(float64(rec.Duration)/float64(time.Second))) * time.Second
item := &feeds.Item{
Title: fmt.Sprintf("Recording %s", rec.RecordedAt.Format("2006-01-02 15:04")),
Link: &feeds.Link{Href: fileURL},
Description: fmt.Sprintf("Icecast stream recording from %s. Duration: %s",
rec.RecordedAt.Format(time.RFC1123), roundedDuration.String()),
Created: rec.RecordedAt,
Id: rec.Hash,
Enclosure: &feeds.Enclosure{
Url: fileURL,
Length: fmt.Sprintf("%d", rec.FileSize),
Type: "audio/mpeg",
},
}
items = append(items, item)
}
return items
}
// scanRecordings scans the recordings directory and returns metadata
func (g *Generator) scanRecordings(maxItems int) ([]RecordingInfo, error) {
var recordings []RecordingInfo
err := filepath.WalkDir(g.recordingsPath, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(strings.ToLower(d.Name()), ".mp3") {
return err
}
// Extract timestamp from filename
matches := recordingPattern.FindStringSubmatch(d.Name())
if len(matches) < 3 {
slog.Debug("Skipping non-conforming filename", "filename", d.Name())
return nil
}
// Parse the timestamp from the filename
timestamp, err := time.Parse("20060102_150405", matches[2])
if err != nil {
slog.Warn("Failed to parse timestamp from filename", "filename", d.Name(), "error", err)
return nil
}
info, err := d.Info()
if err != nil {
slog.Warn("Failed to get file info", "filename", d.Name(), "error", err)
return nil
}
// Get the actual duration from the MP3 file
duration, err := mp3util.GetDuration(path)
if err != nil {
slog.Warn("Failed to get MP3 duration, estimating", "filename", d.Name(), "error", err)
// Estimate: ~128kbps MP3 bitrate = 16KB per second
duration = time.Duration(info.Size()/16000) * time.Second
}
// Generate a stable hash for the recording
filename := filepath.Base(path)
fileHash := hash.GenerateGUID(g.streamName, timestamp, filename)
recordings = append(recordings, RecordingInfo{
Filename: filename,
Hash: fileHash,
FileSize: info.Size(),
Duration: duration,
RecordedAt: timestamp,
})
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk recordings directory: %w", err)
}
// Sort recordings by timestamp (newest first)
sort.Slice(recordings, func(i, j int) bool {
return recordings[i].RecordedAt.After(recordings[j].RecordedAt)
})
// Limit number of items if specified
if maxItems > 0 && maxItems < len(recordings) {
recordings = recordings[:maxItems]
}
return recordings, nil
}