Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

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')
}