184 lines
4.9 KiB
Go
184 lines
4.9 KiB
Go
package resolve
|
|
|
|
import (
|
|
"bufio"
|
|
"io"
|
|
"os"
|
|
pathpkg "path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
)
|
|
|
|
// m3uEntry holds a single parsed M3U entry with optional EXTINF metadata.
|
|
type m3uEntry struct {
|
|
Path string
|
|
Title string
|
|
Duration int // seconds, -1 if unknown
|
|
}
|
|
|
|
// parseM3U reads an M3U stream and extracts entries with EXTINF metadata.
|
|
// Relative paths are resolved against baseDir (empty for remote M3U).
|
|
// Handles UTF-8 BOM, \r\n line endings, missing #EXTM3U header, and bare
|
|
// entries without EXTINF lines.
|
|
// scannerInitBufSize and scannerMaxLineSize configure bufio.Scanners
|
|
// for parsing M3U playlists and yt-dlp JSON output.
|
|
const (
|
|
scannerInitBufSize = 64 * 1024 // initial scanner buffer
|
|
scannerMaxLineSize = 1024 * 1024 // max line length — handles large EXTINF/JSON metadata
|
|
)
|
|
|
|
func parseM3U(r io.Reader, baseDir string) ([]m3uEntry, error) {
|
|
scanner := bufio.NewScanner(r)
|
|
scanner.Buffer(make([]byte, 0, scannerInitBufSize), scannerMaxLineSize)
|
|
var entries []m3uEntry
|
|
var pending *m3uEntry // EXTINF parsed, waiting for path line
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
// Strip UTF-8 BOM if present (common in Windows-created M3U files).
|
|
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
|
line = strings.TrimSpace(line)
|
|
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Skip the #EXTM3U header.
|
|
if strings.HasPrefix(line, "#EXTM3U") {
|
|
continue
|
|
}
|
|
|
|
// Parse #EXTINF:duration,title
|
|
if after, ok := strings.CutPrefix(line, "#EXTINF:"); ok {
|
|
info := after
|
|
dur := -1
|
|
title := ""
|
|
if before, after, ok := strings.Cut(info, ","); ok {
|
|
if d, err := strconv.Atoi(strings.TrimSpace(before)); err == nil {
|
|
dur = d
|
|
}
|
|
title = strings.TrimSpace(after)
|
|
}
|
|
pending = &m3uEntry{Duration: dur, Title: title}
|
|
continue
|
|
}
|
|
|
|
// Skip other comment/directive lines.
|
|
if strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
// This is a path/URL line.
|
|
path := line
|
|
if baseDir != "" && !playlist.IsURL(path) {
|
|
path = resolveM3UPath(baseDir, path)
|
|
}
|
|
|
|
if pending != nil {
|
|
pending.Path = path
|
|
entries = append(entries, *pending)
|
|
pending = nil
|
|
} else {
|
|
entries = append(entries, m3uEntry{Path: path, Duration: -1})
|
|
}
|
|
}
|
|
|
|
return entries, scanner.Err()
|
|
}
|
|
|
|
// m3uEntryToTrack converts a parsed M3U entry to a playlist.Track.
|
|
func m3uEntryToTrack(e m3uEntry) playlist.Track {
|
|
isURL := playlist.IsURL(e.Path)
|
|
duration := max(e.Duration, 0)
|
|
realtime := isURL && e.Duration < 0
|
|
|
|
if e.Title != "" {
|
|
return playlist.Track{
|
|
Path: e.Path,
|
|
Title: e.Title,
|
|
Stream: isURL,
|
|
Realtime: realtime,
|
|
DurationSecs: duration,
|
|
}
|
|
}
|
|
t := playlist.TrackFromPath(e.Path)
|
|
t.Realtime = realtime
|
|
t.DurationSecs = duration
|
|
return t
|
|
}
|
|
|
|
// entriesToTracks converts parsed M3U entries to playlist tracks.
|
|
func entriesToTracks(entries []m3uEntry) []playlist.Track {
|
|
tracks := make([]playlist.Track, 0, len(entries))
|
|
for _, e := range entries {
|
|
tracks = append(tracks, m3uEntryToTrack(e))
|
|
}
|
|
return tracks
|
|
}
|
|
|
|
// resolveLocalM3U opens a local .m3u/.m3u8 file, parses it with EXTINF
|
|
// metadata, and returns the resulting tracks. Relative paths in the M3U
|
|
// are resolved against the directory containing the M3U file.
|
|
func resolveLocalM3U(path string) ([]playlist.Track, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
entries, err := parseM3U(f, filepath.Dir(path))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return entriesToTracks(entries), nil
|
|
}
|
|
|
|
// resolveM3UPath resolves an M3U entry path against a base directory.
|
|
// It returns the entry unchanged for URLs or empty paths.
|
|
// It checks for explicit Windows or POSIX absolute paths via
|
|
// isWindowsAbsolutePath and isPOSIXAbsolutePath, falling back to filepath.IsAbs.
|
|
// If usesWindowsPathSemantics(baseDir) is true, it resolves the path with Windows
|
|
// filepath semantics (filepath.Join and filepath.FromSlash); otherwise, it uses
|
|
// POSIX semantics via pathpkg.Join.
|
|
func resolveM3UPath(baseDir, entry string) string {
|
|
if entry == "" || playlist.IsURL(entry) {
|
|
return entry
|
|
}
|
|
if isWindowsAbsolutePath(entry) {
|
|
return filepath.Clean(entry)
|
|
}
|
|
if isPOSIXAbsolutePath(entry) {
|
|
return pathpkg.Clean(entry)
|
|
}
|
|
if filepath.IsAbs(entry) {
|
|
return filepath.Clean(entry)
|
|
}
|
|
if usesWindowsPathSemantics(baseDir) {
|
|
return filepath.Join(baseDir, filepath.FromSlash(entry))
|
|
}
|
|
return pathpkg.Join(baseDir, entry)
|
|
}
|
|
|
|
func usesWindowsPathSemantics(base string) bool {
|
|
return strings.Contains(base, `\`) || hasWindowsDrivePrefix(base)
|
|
}
|
|
|
|
func isPOSIXAbsolutePath(path string) bool {
|
|
return strings.HasPrefix(path, "/")
|
|
}
|
|
|
|
func isWindowsAbsolutePath(path string) bool {
|
|
return strings.HasPrefix(path, `\\`) || hasWindowsDrivePrefix(path)
|
|
}
|
|
|
|
func hasWindowsDrivePrefix(path string) bool {
|
|
if len(path) < 2 || path[1] != ':' {
|
|
return false
|
|
}
|
|
c := path[0]
|
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
|
}
|