Implement Tuner v1 — Go backend, React frontend, Docker setup
- Go backend: REST API, M3U parser, FFmpeg/MediaMTX process manager - React/Vite frontend: HLS player, admin panel, channel browser (dark theme) - MediaMTX config for RTMP ingest + HLS output - Multi-stage Dockerfile (Go + Bun + Alpine runtime) - docker-compose.yml for single-container deployment - Sample M3U playlist with test streams Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
142
backend/m3u/parser.go
Normal file
142
backend/m3u/parser.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package m3u
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"tuner/models"
|
||||
)
|
||||
|
||||
// ParseFile reads and parses an M3U/M3U8 playlist file into a slice of Channels.
|
||||
func ParseFile(path string) ([]models.Channel, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open playlist: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var channels []models.Channel
|
||||
scanner := bufio.NewScanner(f)
|
||||
|
||||
var extinf string
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if strings.HasPrefix(line, "#EXTINF:") {
|
||||
extinf = line
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip comments and blank lines
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// If we have a pending EXTINF, this line is the URL
|
||||
if extinf != "" {
|
||||
ch := parseExtinf(extinf, line)
|
||||
channels = append(channels, ch)
|
||||
extinf = ""
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read playlist: %w", err)
|
||||
}
|
||||
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
// parseExtinf extracts channel metadata from an #EXTINF line and pairs it with a stream URL.
|
||||
func parseExtinf(extinf string, streamURL string) models.Channel {
|
||||
ch := models.Channel{
|
||||
StreamURL: streamURL,
|
||||
}
|
||||
|
||||
// Extract attributes from the EXTINF line
|
||||
ch.Name = extractDisplayName(extinf)
|
||||
ch.Group = extractAttribute(extinf, "group-title")
|
||||
ch.LogoURL = extractAttribute(extinf, "tvg-logo")
|
||||
|
||||
tvgName := extractAttribute(extinf, "tvg-name")
|
||||
if tvgName != "" && ch.Name == "" {
|
||||
ch.Name = tvgName
|
||||
}
|
||||
|
||||
// Generate stable ID from name + URL
|
||||
ch.ID = generateID(ch.Name, ch.StreamURL)
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// extractAttribute extracts a named attribute value from an EXTINF line.
|
||||
// Example: `#EXTINF:-1 tvg-name="CNN" group-title="News",CNN HD`
|
||||
func extractAttribute(line string, attr string) string {
|
||||
key := attr + `="`
|
||||
idx := strings.Index(line, key)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
start := idx + len(key)
|
||||
end := strings.Index(line[start:], `"`)
|
||||
if end == -1 {
|
||||
return ""
|
||||
}
|
||||
return line[start : start+end]
|
||||
}
|
||||
|
||||
// extractDisplayName extracts the display name (the part after the last comma in the EXTINF line).
|
||||
func extractDisplayName(line string) string {
|
||||
// The display name is after the last comma
|
||||
idx := strings.LastIndex(line, ",")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
|
||||
// generateID creates a stable ID for a channel based on its name and stream URL.
|
||||
func generateID(name, url string) string {
|
||||
h := sha256.Sum256([]byte(name + "|" + url))
|
||||
return fmt.Sprintf("%x", h[:8])
|
||||
}
|
||||
|
||||
// FilterChannels returns channels matching the given search term and/or group.
|
||||
func FilterChannels(channels []models.Channel, search string, group string) []models.Channel {
|
||||
if search == "" && group == "" {
|
||||
return channels
|
||||
}
|
||||
|
||||
searchLower := strings.ToLower(search)
|
||||
var result []models.Channel
|
||||
|
||||
for _, ch := range channels {
|
||||
if group != "" && !strings.EqualFold(ch.Group, group) {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(strings.ToLower(ch.Name), searchLower) {
|
||||
continue
|
||||
}
|
||||
result = append(result, ch)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetGroups returns a deduplicated, sorted list of group names from the channel list.
|
||||
func GetGroups(channels []models.Channel) []string {
|
||||
seen := make(map[string]bool)
|
||||
var groups []string
|
||||
|
||||
for _, ch := range channels {
|
||||
if ch.Group != "" && !seen[ch.Group] {
|
||||
seen[ch.Group] = true
|
||||
groups = append(groups, ch.Group)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
Reference in New Issue
Block a user