mirror of
https://github.com/kemko/icecast-ripper.git
synced 2026-01-01 15:55:42 +03:00
* feat: integrate MP3 duration extraction and silence handling - Added a new dependency on github.com/tcolgate/mp3 for MP3 frame decoding. - Implemented actual MP3 duration retrieval in the scanRecordings function, falling back to an estimation if retrieval fails. - Introduced a silent frame asset for generating silence in audio streams. - Created a silence reader to provide a continuous stream of silent frames. - Added necessary documentation and licensing for the new MP3 package. - Updated .gitignore to exclude MP3 files except for the internal data directory. * feat: update README and improve application configuration and logging * refactor: remove unused GenerateFileHash function and improve resource cleanup in MP3 and recorder modules
179 lines
4.7 KiB
Go
179 lines
4.7 KiB
Go
package rss
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"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)
|
|
|
|
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), rec.Duration.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
|
|
}
|