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

157 lines
3.5 KiB
Go

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
}