package resolve import ( "bufio" "fmt" "io" "os" "sort" "strconv" "strings" "github.com/bjarneo/cliamp/playlist" ) // plsEntry holds a single parsed PLS entry. type plsEntry struct { Num int File string Title string } // parsePLS reads a PLS (INI-style) playlist and returns entries sorted by number. // // Format: // // [playlist] // File1=http://example.com/stream // Title1=Station Name // Length1=-1 // NumberOfEntries=1 // Version=2 func parsePLS(r io.Reader) ([]plsEntry, error) { files := map[int]string{} titles := map[int]string{} scanner := bufio.NewScanner(r) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "[") || strings.HasPrefix(line, ";") { continue } key, val, ok := strings.Cut(line, "=") if !ok { continue } key = strings.TrimSpace(key) val = strings.TrimSpace(val) lower := strings.ToLower(key) switch { case strings.HasPrefix(lower, "file"): if n, err := strconv.Atoi(key[4:]); err == nil { files[n] = val } case strings.HasPrefix(lower, "title"): if n, err := strconv.Atoi(key[5:]); err == nil { titles[n] = val } } } if err := scanner.Err(); err != nil { return nil, err } if len(files) == 0 { return nil, fmt.Errorf("no entries found in PLS playlist") } nums := make([]int, 0, len(files)) for n := range files { nums = append(nums, n) } sort.Ints(nums) entries := make([]plsEntry, 0, len(nums)) for _, n := range nums { entries = append(entries, plsEntry{ Num: n, File: files[n], Title: titles[n], }) } return entries, nil } // plsEntriesToTracks converts parsed PLS entries to playlist tracks. // Radio PLS files list multiple mirror servers for the same stream; // when all entries are stream URLs we collapse them to a single track // using the first URL (matching VLC/Winamp behavior). func plsEntriesToTracks(entries []plsEntry) []playlist.Track { if allStreams(entries) { e := entries[0] title := e.Title // Strip the mirror suffix like "(#1)" from radio station titles. title = stripMirrorSuffix(title) if title == "" { return []playlist.Track{playlist.TrackFromPath(e.File)} } return []playlist.Track{{ Path: e.File, Title: title, Stream: true, Realtime: true, }} } tracks := make([]playlist.Track, 0, len(entries)) for _, e := range entries { if e.Title != "" { tracks = append(tracks, playlist.Track{ Path: e.File, Title: e.Title, Stream: playlist.IsURL(e.File), }) } else { tracks = append(tracks, playlist.TrackFromPath(e.File)) } } return tracks } // allStreams reports whether every PLS entry is an HTTP stream URL. func allStreams(entries []plsEntry) bool { for _, e := range entries { if !playlist.IsURL(e.File) { return false } } return len(entries) >= 1 } // stripMirrorSuffix removes a trailing " (#N)" or " #N" suffix that radio // PLS files use to distinguish mirror servers (e.g. "Groove Salad (#3)"). func stripMirrorSuffix(s string) string { // Handle "(#N)" suffix. if i := strings.LastIndex(s, "(#"); i >= 0 && strings.HasSuffix(s, ")") { return strings.TrimRight(s[:i], " :") } return s } // resolveLocalPLS opens a local .pls file, parses it, and returns the // resulting tracks. func resolveLocalPLS(path string) ([]playlist.Track, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() entries, err := parsePLS(f) if err != nil { return nil, err } return plsEntriesToTracks(entries), nil }