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