chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:13 +08:00
commit ead81af521
414 changed files with 73946 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
package resolve
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestCollectAudioFilesSingleFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "song.mp3")
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got, err := CollectAudioFiles(path)
if err != nil {
t.Fatalf("CollectAudioFiles: %v", err)
}
if len(got) != 1 || got[0] != path {
t.Errorf("got %v, want [%s]", got, path)
}
}
func TestCollectAudioFilesUnsupportedExt(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got, err := CollectAudioFiles(path)
if err != nil {
t.Fatalf("CollectAudioFiles: %v", err)
}
if got != nil {
t.Errorf("non-audio file should return nil, got %v", got)
}
}
func TestCollectAudioFilesDirectoryWalk(t *testing.T) {
dir := t.TempDir()
paths := []string{
filepath.Join(dir, "a.mp3"),
filepath.Join(dir, "nested", "b.flac"),
filepath.Join(dir, "README.txt"), // ignored
filepath.Join(dir, "nested", "c.wav"),
}
for _, p := range paths {
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(p, []byte(""), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
got, err := CollectAudioFiles(dir)
if err != nil {
t.Fatalf("CollectAudioFiles: %v", err)
}
// Expect 3 audio files, sorted.
if len(got) != 3 {
t.Fatalf("got %d files, want 3 — paths=%v", len(got), got)
}
// slices.IsSorted confirms deterministic order.
if !slices.IsSorted(got) {
t.Errorf("files not sorted: %v", got)
}
// README should not be in the result.
for _, p := range got {
if strings.HasSuffix(p, "README.txt") {
t.Errorf("README included in audio files: %v", got)
}
}
}
func TestCollectAudioFilesMissing(t *testing.T) {
_, err := CollectAudioFiles(filepath.Join(t.TempDir(), "nonexistent"))
if err == nil {
t.Error("CollectAudioFiles on missing path should error")
}
}
func TestArgsLocalFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "song.mp3")
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got, err := Args([]string{path})
if err != nil {
t.Fatalf("Args: %v", err)
}
if len(got.Tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(got.Tracks))
}
if got.Tracks[0].Path != path {
t.Errorf("Path = %q, want %q", got.Tracks[0].Path, path)
}
if len(got.Pending) != 0 {
t.Errorf("Pending = %v, want empty", got.Pending)
}
}
func TestArgsLocalDirectory(t *testing.T) {
dir := t.TempDir()
files := []string{"a.mp3", "b.flac", "c.ogg"}
for _, f := range files {
p := filepath.Join(dir, f)
if err := os.WriteFile(p, []byte(""), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
got, err := Args([]string{dir})
if err != nil {
t.Fatalf("Args: %v", err)
}
if len(got.Tracks) != 3 {
t.Fatalf("got %d tracks, want 3", len(got.Tracks))
}
// Results preserve on-disk sort order (CollectAudioFiles sorts).
names := make([]string, len(got.Tracks))
for i, tr := range got.Tracks {
names[i] = filepath.Base(tr.Path)
}
if !slices.IsSorted(names) {
t.Errorf("tracks not sorted: %v", names)
}
}
func TestArgsRemoteFeedURLGoesToPending(t *testing.T) {
// .xml URL is recognised as a feed.
feedURL := "https://example.com/podcast.xml"
got, err := Args([]string{feedURL})
if err != nil {
t.Fatalf("Args: %v", err)
}
if len(got.Pending) != 1 || got.Pending[0] != feedURL {
t.Errorf("Pending = %v, want [%s]", got.Pending, feedURL)
}
if len(got.Tracks) != 0 {
t.Errorf("Tracks should be empty, got %+v", got.Tracks)
}
}
func TestArgsRemoteAudioURLBecomesTrack(t *testing.T) {
audioURL := "https://stream.example.com/song.mp3"
got, err := Args([]string{audioURL})
if err != nil {
t.Fatalf("Args: %v", err)
}
if len(got.Tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(got.Tracks))
}
if got.Tracks[0].Path != audioURL {
t.Errorf("Path = %q, want %q", got.Tracks[0].Path, audioURL)
}
}
func TestArgsLocalM3U(t *testing.T) {
dir := t.TempDir()
// Write a local m3u that points at a dummy audio file.
audio := filepath.Join(dir, "song.mp3")
if err := os.WriteFile(audio, []byte(""), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
m3u := filepath.Join(dir, "pl.m3u")
content := `#EXTM3U
#EXTINF:120,My Song
song.mp3
`
if err := os.WriteFile(m3u, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got, err := Args([]string{m3u})
if err != nil {
t.Fatalf("Args: %v", err)
}
if len(got.Tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(got.Tracks))
}
if got.Tracks[0].Title != "My Song" {
t.Errorf("Title = %q, want My Song", got.Tracks[0].Title)
}
}
func TestArgsLocalPLS(t *testing.T) {
dir := t.TempDir()
audio := filepath.Join(dir, "song.mp3")
if err := os.WriteFile(audio, []byte(""), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
pls := filepath.Join(dir, "pl.pls")
content := "[playlist]\nFile1=" + audio + "\nTitle1=Track 1\nLength1=60\nNumberOfEntries=1\nVersion=2\n"
if err := os.WriteFile(pls, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got, err := Args([]string{pls})
if err != nil {
t.Fatalf("Args: %v", err)
}
if len(got.Tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(got.Tracks))
}
if got.Tracks[0].Title != "Track 1" {
t.Errorf("Title = %q, want Track 1", got.Tracks[0].Title)
}
}
func TestArgsMissingFileErrors(t *testing.T) {
// CollectAudioFiles stat's the path and Args wraps the os.Stat failure
// into "scanning X: ..." — missing paths are surfaced, not swallowed.
path := filepath.Join(t.TempDir(), "ghost.mp3")
_, err := Args([]string{path})
if err == nil {
t.Error("Args for missing file should error")
}
if !strings.Contains(err.Error(), "scanning") {
t.Errorf("error = %q, want to mention 'scanning'", err.Error())
}
}
func TestScanTracksEmpty(t *testing.T) {
got := scanTracks(nil)
if got != nil {
t.Errorf("scanTracks(nil) = %v, want nil", got)
}
got = scanTracks([]string{})
if got != nil {
t.Errorf("scanTracks(empty) = %v, want nil", got)
}
}
func TestScanTracksPreservesOrder(t *testing.T) {
dir := t.TempDir()
paths := make([]string, 12) // more than workers = 8
for i := range paths {
paths[i] = filepath.Join(dir, "a.mp3")
}
tracks := scanTracks(paths)
if len(tracks) != len(paths) {
t.Fatalf("len = %d, want %d", len(tracks), len(paths))
}
for i, tr := range tracks {
if tr.Path != paths[i] {
t.Errorf("tracks[%d].Path = %q, want %q", i, tr.Path, paths[i])
}
}
}
+183
View File
@@ -0,0 +1,183 @@
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')
}
+232
View File
@@ -0,0 +1,232 @@
package resolve
import (
"path/filepath"
"strings"
"testing"
)
func TestParseM3UBasic(t *testing.T) {
input := `#EXTM3U
#EXTINF:120,Artist - Song One
http://example.com/song1.mp3
#EXTINF:180,Artist - Song Two
http://example.com/song2.mp3
`
entries, err := parseM3U(strings.NewReader(input), "")
if err != nil {
t.Fatalf("parseM3U error: %v", err)
}
if len(entries) != 2 {
t.Fatalf("got %d entries, want 2", len(entries))
}
if entries[0].Title != "Artist - Song One" {
t.Errorf("entry[0].Title = %q, want %q", entries[0].Title, "Artist - Song One")
}
if entries[0].Duration != 120 {
t.Errorf("entry[0].Duration = %d, want 120", entries[0].Duration)
}
if entries[0].Path != "http://example.com/song1.mp3" {
t.Errorf("entry[0].Path = %q", entries[0].Path)
}
if entries[1].Title != "Artist - Song Two" {
t.Errorf("entry[1].Title = %q", entries[1].Title)
}
if entries[1].Duration != 180 {
t.Errorf("entry[1].Duration = %d, want 180", entries[1].Duration)
}
}
func TestParseM3UNoHeader(t *testing.T) {
// M3U without #EXTM3U header should still work
input := `http://example.com/stream1.mp3
http://example.com/stream2.mp3
`
entries, err := parseM3U(strings.NewReader(input), "")
if err != nil {
t.Fatalf("parseM3U error: %v", err)
}
if len(entries) != 2 {
t.Fatalf("got %d entries, want 2", len(entries))
}
if entries[0].Duration != -1 {
t.Errorf("bare entry Duration = %d, want -1", entries[0].Duration)
}
}
func TestParseM3UBOM(t *testing.T) {
// UTF-8 BOM prefix should be stripped
input := "\xef\xbb\xbf#EXTM3U\n#EXTINF:60,Song\nhttp://example.com/song.mp3\n"
entries, err := parseM3U(strings.NewReader(input), "")
if err != nil {
t.Fatalf("parseM3U error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
if entries[0].Title != "Song" {
t.Errorf("Title = %q, want Song", entries[0].Title)
}
}
func TestParseM3USkipsComments(t *testing.T) {
input := `#EXTM3U
# This is a comment
#EXTINF:60,Song
http://example.com/song.mp3
#EXTVLCOPT:some-option
`
entries, err := parseM3U(strings.NewReader(input), "")
if err != nil {
t.Fatalf("parseM3U error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
}
func TestParseM3URelativePaths(t *testing.T) {
input := `#EXTM3U
#EXTINF:60,Song
music/song.mp3
`
entries, err := parseM3U(strings.NewReader(input), "/home/user")
if err != nil {
t.Fatalf("parseM3U error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
if entries[0].Path != "/home/user/music/song.mp3" {
t.Errorf("Path = %q, want /home/user/music/song.mp3", entries[0].Path)
}
}
func TestParseM3UAbsolutePaths(t *testing.T) {
input := "/absolute/path/song.mp3\n"
entries, err := parseM3U(strings.NewReader(input), "/home/user")
if err != nil {
t.Fatalf("parseM3U error: %v", err)
}
if entries[0].Path != "/absolute/path/song.mp3" {
t.Errorf("Path = %q, want /absolute/path/song.mp3", entries[0].Path)
}
}
func TestParseM3UEmpty(t *testing.T) {
entries, err := parseM3U(strings.NewReader(""), "")
if err != nil {
t.Fatalf("parseM3U error: %v", err)
}
if len(entries) != 0 {
t.Fatalf("got %d entries, want 0", len(entries))
}
}
func TestParseM3URadioStream(t *testing.T) {
// Radio stream with -1 duration
input := `#EXTM3U
#EXTINF:-1,Radio Station
http://radio.example.com/stream
`
entries, err := parseM3U(strings.NewReader(input), "")
if err != nil {
t.Fatalf("parseM3U error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
if entries[0].Duration != -1 {
t.Errorf("Duration = %d, want -1", entries[0].Duration)
}
if entries[0].Title != "Radio Station" {
t.Errorf("Title = %q, want Radio Station", entries[0].Title)
}
}
func TestM3UEntryToTrackStream(t *testing.T) {
e := m3uEntry{
Path: "http://radio.example.com/stream",
Title: "Radio Station",
Duration: -1,
}
tr := m3uEntryToTrack(e)
if !tr.Stream {
t.Error("Stream should be true for URL")
}
if !tr.Realtime {
t.Error("Realtime should be true for stream with -1 duration")
}
if tr.DurationSecs != 0 {
t.Errorf("DurationSecs = %d, want 0 (negative clamped)", tr.DurationSecs)
}
if tr.Title != "Radio Station" {
t.Errorf("Title = %q, want Radio Station", tr.Title)
}
}
func TestM3UEntryToTrackFile(t *testing.T) {
e := m3uEntry{
Path: "/home/user/song.mp3",
Title: "My Song",
Duration: 180,
}
tr := m3uEntryToTrack(e)
if tr.Stream {
t.Error("Stream should be false for local file")
}
if tr.Realtime {
t.Error("Realtime should be false for local file")
}
if tr.DurationSecs != 180 {
t.Errorf("DurationSecs = %d, want 180", tr.DurationSecs)
}
}
func TestResolveM3UPathWindows(t *testing.T) {
tests := []struct {
name string
baseDir string
entry string
want string
}{
{
name: "relative backslash",
baseDir: `C:\Music`,
entry: `artist\song.mp3`,
want: filepath.Clean(filepath.Join(`C:\Music`, filepath.FromSlash(`artist\song.mp3`))),
},
{
name: "relative forward slash",
baseDir: `C:\Music`,
entry: `artist/song.mp3`,
want: filepath.Clean(filepath.Join(`C:\Music`, filepath.FromSlash(`artist/song.mp3`))),
},
{
name: "absolute drive-letter",
baseDir: `C:\Music`,
entry: `D:\Other\track.mp3`,
want: filepath.Clean(`D:\Other\track.mp3`),
},
{
name: "absolute UNC path",
baseDir: `\\server\share`,
entry: `sub\file.mp3`,
want: filepath.Clean(filepath.Join(`\\server\share`, filepath.FromSlash(`sub\file.mp3`))),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveM3UPath(tt.baseDir, tt.entry)
if got != tt.want {
t.Fatalf("resolveM3UPath(%q, %q) = %q, want %q", tt.baseDir, tt.entry, got, tt.want)
}
})
}
}
+156
View File
@@ -0,0 +1,156 @@
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
}
+209
View File
@@ -0,0 +1,209 @@
package resolve
import (
"strings"
"testing"
)
func TestParsePLSBasic(t *testing.T) {
input := `[playlist]
File1=http://radio.example.com/stream1
Title1=Station One
Length1=-1
File2=http://radio.example.com/stream2
Title2=Station Two
Length2=-1
NumberOfEntries=2
Version=2
`
entries, err := parsePLS(strings.NewReader(input))
if err != nil {
t.Fatalf("parsePLS error: %v", err)
}
if len(entries) != 2 {
t.Fatalf("got %d entries, want 2", len(entries))
}
if entries[0].File != "http://radio.example.com/stream1" {
t.Errorf("entry[0].File = %q", entries[0].File)
}
if entries[0].Title != "Station One" {
t.Errorf("entry[0].Title = %q", entries[0].Title)
}
if entries[0].Num != 1 {
t.Errorf("entry[0].Num = %d, want 1", entries[0].Num)
}
if entries[1].File != "http://radio.example.com/stream2" {
t.Errorf("entry[1].File = %q", entries[1].File)
}
if entries[1].Title != "Station Two" {
t.Errorf("entry[1].Title = %q", entries[1].Title)
}
}
func TestParsePLSSortedByNumber(t *testing.T) {
// Entries out of order should be sorted
input := `[playlist]
File3=http://example.com/3
Title3=Third
File1=http://example.com/1
Title1=First
File2=http://example.com/2
Title2=Second
`
entries, err := parsePLS(strings.NewReader(input))
if err != nil {
t.Fatalf("parsePLS error: %v", err)
}
if len(entries) != 3 {
t.Fatalf("got %d entries, want 3", len(entries))
}
if entries[0].Title != "First" {
t.Errorf("entry[0].Title = %q, want First", entries[0].Title)
}
if entries[1].Title != "Second" {
t.Errorf("entry[1].Title = %q, want Second", entries[1].Title)
}
if entries[2].Title != "Third" {
t.Errorf("entry[2].Title = %q, want Third", entries[2].Title)
}
}
func TestParsePLSEmpty(t *testing.T) {
input := `[playlist]
NumberOfEntries=0
Version=2
`
_, err := parsePLS(strings.NewReader(input))
if err == nil {
t.Fatal("parsePLS should return error for empty playlist")
}
}
func TestParsePLSSkipsSectionAndComments(t *testing.T) {
input := `[playlist]
; This is a comment
File1=http://example.com/stream
Title1=Station
`
entries, err := parsePLS(strings.NewReader(input))
if err != nil {
t.Fatalf("parsePLS error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
}
func TestStripMirrorSuffix(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Groove Salad (#3)", "Groove Salad"},
{"Station Name (#1)", "Station Name"},
{"No Mirror Suffix", "No Mirror Suffix"},
{"", ""},
{"Station: (#2)", "Station"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := stripMirrorSuffix(tt.input); got != tt.want {
t.Errorf("stripMirrorSuffix(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestAllStreams(t *testing.T) {
tests := []struct {
name string
entries []plsEntry
want bool
}{
{
"all URLs",
[]plsEntry{{File: "http://a.com/s"}, {File: "https://b.com/s"}},
true,
},
{
"mixed",
[]plsEntry{{File: "http://a.com/s"}, {File: "/local/file.mp3"}},
false,
},
{
"all local",
[]plsEntry{{File: "/a.mp3"}, {File: "/b.mp3"}},
false,
},
{
"empty",
[]plsEntry{},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := allStreams(tt.entries); got != tt.want {
t.Errorf("allStreams() = %v, want %v", got, tt.want)
}
})
}
}
func TestPlsEntriesToTracksCollapsesMirrors(t *testing.T) {
// When all entries are streams, should collapse to a single track
entries := []plsEntry{
{Num: 1, File: "http://a.com/stream", Title: "Station (#1)"},
{Num: 2, File: "http://b.com/stream", Title: "Station (#2)"},
}
tracks := plsEntriesToTracks(entries)
if len(tracks) != 1 {
t.Fatalf("got %d tracks, want 1 (mirrors collapsed)", len(tracks))
}
if tracks[0].Title != "Station" {
t.Errorf("Title = %q, want Station (mirror suffix stripped)", tracks[0].Title)
}
if !tracks[0].Stream || !tracks[0].Realtime {
t.Error("collapsed stream should be Stream=true, Realtime=true")
}
}
func TestPlsEntriesToTracksMultipleTracks(t *testing.T) {
// Mixed local/remote entries should produce individual tracks
entries := []plsEntry{
{Num: 1, File: "/home/user/song.mp3", Title: "Song One"},
{Num: 2, File: "/home/user/song2.mp3", Title: "Song Two"},
}
tracks := plsEntriesToTracks(entries)
if len(tracks) != 2 {
t.Fatalf("got %d tracks, want 2", len(tracks))
}
if tracks[0].Title != "Song One" {
t.Errorf("track[0].Title = %q, want Song One", tracks[0].Title)
}
}
func TestHumanizeBasename(t *testing.T) {
tests := []struct {
input string
want string
}{
{"clr-podcast-467", "clr podcast 467"},
{"no-dashes-here", "no dashes here"},
{"nodashes", "nodashes"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := humanizeBasename(tt.input); got != tt.want {
t.Errorf("humanizeBasename(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
+681
View File
@@ -0,0 +1,681 @@
// Package resolve converts CLI arguments (files, directories, globs, URLs,
// M3U playlists, and RSS feeds) into a flat list of playlist tracks.
package resolve
import (
"bufio"
"bytes"
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/kkdai/youtube/v2"
)
// ytdlCookiesFromVal stores the browser name passed to yt-dlp's
// --cookies-from-browser flag. atomic.Value allows lock-free reads on the
// resolve hot path while permitting late binding from main.go.
var ytdlCookiesFromVal atomic.Value
// SetYTDLCookiesFrom configures yt-dlp to use cookies from the given browser
// (e.g. "firefox", "chrome", "brave") for any --flat-playlist resolution.
// Pass an empty string to disable.
func SetYTDLCookiesFrom(browser string) {
ytdlCookiesFromVal.Store(browser)
}
func ytdlCookiesFrom() string {
v, _ := ytdlCookiesFromVal.Load().(string)
return v
}
// httpClient is used for feed and M3U resolution. It has a generous but
// finite timeout to prevent hanging on unresponsive servers.
var httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &uaTransport{rt: http.DefaultTransport},
}
// sniffClient probes content types during Args classification, which runs on
// the startup path before the TUI launches. It uses a short timeout so a slow
// or unresponsive server can stall startup by at most a few seconds rather
// than the 30s the feed/M3U client allows.
var sniffClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &uaTransport{rt: http.DefaultTransport},
}
// uaTransport injects the cliamp User-Agent header into every request.
type uaTransport struct{ rt http.RoundTripper }
func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("User-Agent", "cliamp/1.0 (https://github.com/bjarneo/cliamp)")
return t.rt.RoundTrip(req)
}
// Result holds the output of Args: instantly-resolved tracks and
// remote URLs (feeds, M3U) that need async HTTP fetching.
type Result struct {
Tracks []playlist.Track // local files, dirs, plain stream URLs
Pending []string // feed/M3U URLs to resolve asynchronously
}
// Args separates CLI arguments into immediately-resolved local tracks
// and pending remote URLs (feeds, M3U) that require HTTP fetching.
func Args(args []string) (Result, error) {
var r Result
var files []string
for _, arg := range args {
if playlist.IsURL(arg) {
if playlist.IsFeed(arg) || playlist.IsM3U(arg) || playlist.IsPLS(arg) || playlist.IsYouTubeURL(arg) || playlist.IsYTDL(arg) || playlist.IsXiaoyuzhouEpisode(arg) || sniffFeedURL(arg) {
r.Pending = append(r.Pending, arg)
} else {
files = append(files, arg)
}
continue
}
matches, err := filepath.Glob(arg)
if err != nil || len(matches) == 0 {
matches = []string{arg}
}
for _, path := range matches {
if playlist.IsLocalM3U(path) {
tracks, err := resolveLocalM3U(path)
if err != nil {
return r, fmt.Errorf("loading m3u %s: %w", path, err)
}
r.Tracks = append(r.Tracks, tracks...)
continue
}
if playlist.IsLocalPLS(path) {
tracks, err := resolveLocalPLS(path)
if err != nil {
return r, fmt.Errorf("loading pls %s: %w", path, err)
}
r.Tracks = append(r.Tracks, tracks...)
continue
}
resolved, err := CollectAudioFiles(path)
if err != nil {
return r, fmt.Errorf("scanning %s: %w", path, err)
}
files = append(files, resolved...)
}
}
r.Tracks = append(r.Tracks, scanTracks(files)...)
return r, nil
}
// Remote fetches feed and M3U URLs and returns the resolved tracks.
func Remote(urls []string) ([]playlist.Track, error) {
var tracks []playlist.Track
for _, u := range urls {
switch {
case playlist.IsXiaoyuzhouEpisode(u):
t, err := resolveXiaoyuzhouEpisode(u)
if err != nil {
return nil, fmt.Errorf("resolving xiaoyuzhou episode %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsYouTubeMusicURL(u):
// YouTube Music requires yt-dlp; the native YouTube API client
// does not support music.youtube.com playlists.
t, err := resolveYTDL(u)
if err != nil {
return nil, fmt.Errorf("resolving youtube music %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsYouTubeURL(u):
t, err := resolveYouTube(u)
if err != nil {
return nil, fmt.Errorf("resolving youtube %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsYTDL(u):
t, err := resolveYTDL(u)
if err != nil {
return nil, fmt.Errorf("resolving yt-dlp %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsFeed(u):
t, err := resolveFeed(u)
if err != nil {
return nil, fmt.Errorf("resolving feed %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsM3U(u):
t, err := resolveM3U(u)
if err != nil {
return nil, fmt.Errorf("resolving m3u %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsPLS(u):
t, err := resolvePLS(u)
if err != nil {
return nil, fmt.Errorf("resolving pls %s: %w", u, err)
}
tracks = append(tracks, t...)
default:
// URL was classified as a feed by content-type sniffing.
t, err := resolveFeed(u)
if err != nil {
return nil, fmt.Errorf("resolving feed %s: %w", u, err)
}
tracks = append(tracks, t...)
}
}
return tracks, nil
}
// sniffFeedURL does a HEAD request and returns true if the Content-Type
// indicates an RSS/Atom feed. Used as a fallback when the URL has no
// recognizable file extension (e.g. https://feeds.megaphone.fm/GLT1412515089).
func sniffFeedURL(rawURL string) bool {
// URLs with a known audio extension are never feeds — skip the
// network round-trip to avoid misclassification when CDNs return
// unexpected Content-Types for HEAD requests.
if u, err := url.Parse(rawURL); err == nil {
if player.SupportedExts[strings.ToLower(filepath.Ext(u.Path))] {
return false
}
}
resp, err := sniffClient.Head(rawURL)
if err != nil {
return false
}
resp.Body.Close()
ct := resp.Header.Get("Content-Type")
mediaType, _, _ := mime.ParseMediaType(ct)
switch mediaType {
case "application/rss+xml", "application/atom+xml",
"application/xml", "text/xml":
return true
}
return false
}
// CollectAudioFiles returns audio file paths for the given argument.
// If path is a directory, it walks it recursively collecting supported files.
// If path is a file with a supported extension, it returns it directly.
func CollectAudioFiles(path string) ([]string, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if !info.IsDir() {
if player.SupportedExts[strings.ToLower(filepath.Ext(path))] {
return []string{path}, nil
}
return nil, nil
}
var files []string
err = filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && player.SupportedExts[strings.ToLower(filepath.Ext(p))] {
files = append(files, p)
}
return nil
})
if err != nil {
return nil, err
}
slices.Sort(files)
return files, nil
}
// LocalPlaylist resolves a local M3U/M3U8 or PLS playlist file into tracks.
// Relative paths are resolved by the underlying parser against the playlist
// file's directory, matching normal playback import behavior.
func LocalPlaylist(path string) ([]playlist.Track, error) {
if playlist.IsLocalM3U(path) {
return resolveLocalM3U(path)
}
if playlist.IsLocalPLS(path) {
return resolveLocalPLS(path)
}
return nil, fmt.Errorf("unsupported playlist file %q", path)
}
// scanTracks converts file paths to Tracks concurrently, preserving order.
func scanTracks(files []string) []playlist.Track {
if len(files) == 0 {
return nil
}
tracks := make([]playlist.Track, len(files))
workers := min(len(files), 8)
var wg sync.WaitGroup
ch := make(chan int, len(files))
for i := range files {
ch <- i
}
close(ch)
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
for i := range ch {
tracks[i] = playlist.TrackFromPath(files[i])
}
}()
}
wg.Wait()
return tracks
}
// resolveFeed fetches a podcast RSS feed and returns tracks with metadata.
func resolveFeed(feedURL string) ([]playlist.Track, error) {
resp, err := httpClient.Get(feedURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http status %s", resp.Status)
}
var rss struct {
Channel struct {
Title string `xml:"title"`
Items []struct {
Title string `xml:"title"`
Duration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
Enclosure struct {
URL string `xml:"url,attr"`
Type string `xml:"type,attr"`
} `xml:"enclosure"`
} `xml:"item"`
} `xml:"channel"`
}
// Bound the read so a huge or malicious feed can't exhaust memory.
const maxFeedBody = 32 << 20 // 32 MB
if err := xml.NewDecoder(io.LimitReader(resp.Body, maxFeedBody)).Decode(&rss); err != nil {
return nil, fmt.Errorf("parsing feed: %w", err)
}
var tracks []playlist.Track
for _, item := range rss.Channel.Items {
if item.Enclosure.URL == "" {
continue
}
tracks = append(tracks, playlist.Track{
Path: item.Enclosure.URL,
Title: item.Title,
Artist: rss.Channel.Title,
Stream: true,
DurationSecs: parseItunesDuration(item.Duration),
})
}
return tracks, nil
}
// maxM3UBody caps how much of a remote playlist we read before classifying it.
// HLS/M3U playlists are tiny (a few KB); 1 MB is a generous safety bound.
const maxM3UBody = 1 << 20
// resolveM3U fetches an M3U/M3U8 URL. HLS playlists (master or media) are a
// single live/VOD stream — not a track list — so the original URL is handed to
// the player, where ffmpeg resolves the relative chunklist/segment URIs and
// follows the live segment window. Plain M3U files are parsed as track lists.
func resolveM3U(m3uURL string) ([]playlist.Track, error) {
resp, err := httpClient.Get(m3uURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http status %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxM3UBody))
if err != nil {
return nil, err
}
if isHLSPlaylist(body) {
// Parsing an HLS playlist as a track list would extract the relative
// "chunklist_*.m3u8" URI as a bogus local-file track and fail with
// "open source: ... no such file or directory".
t := playlist.TrackFromPath(m3uURL) // Stream=true; title derived from URL
// #EXT-X-ENDLIST only appears in media playlists, so VOD behind a
// master playlist is conservatively treated as live.
t.Realtime = !bytes.Contains(body, []byte("#EXT-X-ENDLIST"))
return []playlist.Track{t}, nil
}
entries, err := parseM3U(bytes.NewReader(body), "")
if err != nil {
return nil, err
}
return entriesToTracks(entries), nil
}
// resolvePLS fetches a PLS playlist URL and returns tracks.
func resolvePLS(plsURL string) ([]playlist.Track, error) {
resp, err := httpClient.Get(plsURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http status %s", resp.Status)
}
entries, err := parsePLS(resp.Body)
if err != nil {
return nil, err
}
return plsEntriesToTracks(entries), nil
}
// isHLSPlaylist reports whether an M3U body is an HLS playlist (master or media)
// rather than a plain list of media URLs. HLS is identified by its #EXT-X-* tags.
func isHLSPlaylist(body []byte) bool {
return bytes.Contains(body, []byte("#EXT-X-STREAM-INF")) || // master
bytes.Contains(body, []byte("#EXT-X-TARGETDURATION")) || // media
bytes.Contains(body, []byte("#EXT-X-MEDIA-SEQUENCE")) // media
}
// ytdlFlatEntry holds JSON fields from yt-dlp --flat-playlist output.
type ytdlFlatEntry struct {
URL string `json:"url"`
WebpageURL string `json:"webpage_url"`
Title string `json:"title"`
Uploader string `json:"uploader"`
PlaylistUploader string `json:"playlist_uploader"`
WebpageURLBasename string `json:"webpage_url_basename"`
Duration float64 `json:"duration"`
}
// ytdlFullEntry holds JSON fields from yt-dlp --print-json output (download mode).
type ytdlFullEntry struct {
Title string `json:"title"`
Uploader string `json:"uploader"`
Filename string `json:"_filename"`
}
// YTDLRadioInitialItems is the number of tracks fetched in the first pass
// for YouTube Radio/Mix playlists. The UI uses this as the batch offset
// when starting incremental loading.
const YTDLRadioInitialItems = 20
// resolveYouTube uses the kkdai/youtube library to resolve YouTube URLs.
// For playlist URLs it enumerates all entries natively; for single video URLs
// it returns a single track with metadata from the YouTube API.
func resolveYouTube(pageURL string) ([]playlist.Track, error) {
client := youtube.Client{}
// Only attempt playlist resolution if the URL contains a "list=" parameter,
// avoiding a wasted API call for single-video URLs (the common case).
u, _ := url.Parse(pageURL)
isList := u != nil && u.Query().Get("list") != ""
// YouTube Radio/Mix playlists (list=RD...) are dynamically generated and
// unsupported by the native library. Route them directly to yt-dlp.
listID := ""
if u != nil {
listID = u.Query().Get("list")
}
if isList && strings.HasPrefix(listID, "RD") {
if tracks, err := resolveYTDL(pageURL, YTDLRadioInitialItems); err == nil && len(tracks) > 0 {
return tracks, nil
}
}
if isList {
pl, err := client.GetPlaylist(pageURL)
if err == nil && len(pl.Videos) > 0 {
tracks := make([]playlist.Track, 0, len(pl.Videos))
for _, entry := range pl.Videos {
tracks = append(tracks, playlist.Track{
Path: "https://www.youtube.com/watch?v=" + entry.ID,
Title: entry.Title,
Artist: entry.Author,
Stream: true,
DurationSecs: int(entry.Duration.Seconds()),
})
}
return tracks, nil
}
// Native library failed (e.g. YouTube Radio/Mix playlists are dynamic
// and unsupported). Fall back to yt-dlp which handles them.
if tracks, err := resolveYTDL(pageURL); err == nil && len(tracks) > 0 {
return tracks, nil
}
}
// Single video.
video, err := client.GetVideo(pageURL)
if err != nil {
return nil, fmt.Errorf("youtube resolve: %w", err)
}
return []playlist.Track{{
Path: "https://www.youtube.com/watch?v=" + video.ID,
Title: video.Title,
Artist: video.Author,
Stream: true,
DurationSecs: int(video.Duration.Seconds()),
}}, nil
}
// ResolveYTDLBatch is like resolveYTDL but fetches a specific range
// [start, start+count) from the playlist. Exported for UI incremental loading.
// ResolveYTDLBatch fetches tracks starting at offset `start`.
// If count > 0, fetches at most `count` items; if count == 0, fetches all remaining.
func ResolveYTDLBatch(pageURL string, start, count int) ([]playlist.Track, error) {
end := 0
if count > 0 {
end = start + count
}
return resolveYTDLRange(pageURL, start, end)
}
// resolveYTDL uses yt-dlp --flat-playlist to quickly enumerate tracks.
// Tracks are returned with their page URLs as Path (not direct audio URLs).
// If maxItems > 0, only the first maxItems tracks are fetched.
func resolveYTDL(pageURL string, maxItems ...int) ([]playlist.Track, error) {
end := 0
if len(maxItems) > 0 {
end = maxItems[0]
}
return resolveYTDLRange(pageURL, 0, end)
}
func resolveYTDLRange(pageURL string, start, end int) ([]playlist.Track, error) {
if _, err := exec.LookPath("yt-dlp"); err != nil {
return nil, fmt.Errorf("yt-dlp not found in PATH — see https://github.com/yt-dlp/yt-dlp#installation")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
args := []string{"--flat-playlist", "-j", "--socket-timeout", "15"}
if browser := ytdlCookiesFrom(); browser != "" {
args = append(args, "--cookies-from-browser", browser)
}
if start > 0 {
args = append(args, "--playlist-start", strconv.Itoa(start+1)) // yt-dlp is 1-based
}
if end > 0 {
args = append(args, "--playlist-end", strconv.Itoa(end))
}
args = append(args, pageURL)
cmd := exec.CommandContext(ctx, "yt-dlp", args...)
var stderr strings.Builder
cmd.Stderr = &stderr
stdout, err := cmd.Output()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("yt-dlp: timed out resolving %s (30s)", pageURL)
}
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return nil, fmt.Errorf("yt-dlp: %s", msg)
}
return nil, fmt.Errorf("yt-dlp: %w", err)
}
var tracks []playlist.Track
scanner := bufio.NewScanner(strings.NewReader(string(stdout)))
// yt-dlp JSON can exceed bufio.Scanner's default 64KB token limit
// (e.g. videos with very long descriptions).
scanner.Buffer(make([]byte, 0, scannerInitBufSize), scannerMaxLineSize)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var e ytdlFlatEntry
if err := json.Unmarshal([]byte(line), &e); err != nil {
continue
}
trackURL := e.WebpageURL
if trackURL == "" {
trackURL = e.URL
}
if trackURL == "" {
continue
}
title := e.Title
if title == "" {
title = humanizeBasename(e.WebpageURLBasename)
}
if title == "" {
title = trackURL
}
artist := e.Uploader
if artist == "" {
artist = e.PlaylistUploader
}
tracks = append(tracks, playlist.Track{
Path: trackURL,
Title: title,
Artist: artist,
Stream: true,
DurationSecs: int(e.Duration),
})
}
return tracks, scanner.Err()
}
// DownloadYTDL downloads a single track via yt-dlp to the given directory
// and returns the output file path. Uses yt-dlp's default naming template.
func DownloadYTDL(pageURL, saveDir string) (string, error) {
if _, err := exec.LookPath("yt-dlp"); err != nil {
return "", fmt.Errorf("yt-dlp not found in PATH")
}
outTemplate := filepath.Join(saveDir, "%(artist,uploader)s - %(title)s.%(ext)s")
cmd := exec.Command("yt-dlp",
"-f", "bestaudio[protocol=https]/bestaudio[protocol=http]/bestaudio",
"--no-playlist",
"--print-json",
"-o", outTemplate,
pageURL)
var stderr strings.Builder
cmd.Stderr = &stderr
stdout, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return "", fmt.Errorf("yt-dlp: %s", msg)
}
return "", fmt.Errorf("yt-dlp: %w", err)
}
var e ytdlFullEntry
if err := json.Unmarshal(stdout, &e); err != nil {
return "", fmt.Errorf("parsing yt-dlp output: %w", err)
}
if e.Filename == "" {
return "", fmt.Errorf("yt-dlp: no file downloaded for %s", pageURL)
}
return e.Filename, nil
}
// parseItunesDuration parses an <itunes:duration> value into seconds.
// Accepts "HH:MM:SS", "MM:SS", or a plain seconds string (integer or float).
// Returns 0 for any invalid or negative input.
func parseItunesDuration(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
// parseSec handles fractional seconds (e.g. "61.5" → 61).
parseSec := func(s string) (int, error) {
f, err := strconv.ParseFloat(s, 64)
return int(f), err
}
parts := strings.Split(s, ":")
var result int
switch len(parts) {
case 1:
n, err := parseSec(parts[0])
if err != nil {
return 0
}
result = n
case 2:
m, err1 := strconv.Atoi(parts[0])
sec, err2 := parseSec(parts[1])
if err1 != nil || err2 != nil {
return 0
}
result = m*60 + sec
case 3:
h, err1 := strconv.Atoi(parts[0])
m, err2 := strconv.Atoi(parts[1])
sec, err3 := parseSec(parts[2])
if err1 != nil || err2 != nil || err3 != nil {
return 0
}
result = h*3600 + m*60 + sec
default:
return 0
}
if result < 0 {
return 0
}
return result
}
// humanizeBasename converts a URL basename like "clr-podcast-467" into "clr podcast 467".
// A trailing known audio extension (e.g. "track.mp3") is dropped so it doesn't
// leak into the title; non-media suffixes (e.g. "3.5-remix") are left intact.
func humanizeBasename(s string) string {
if ext := filepath.Ext(s); ext != "" && player.SupportedExts[strings.ToLower(ext)] {
s = strings.TrimSuffix(s, ext)
}
return strings.ReplaceAll(s, "-", " ")
}
+226
View File
@@ -0,0 +1,226 @@
package resolve
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
)
func TestArgsTreatsXiaoyuzhouEpisodeAsPending(t *testing.T) {
url := "https://www.xiaoyuzhoufm.com/episode/69a13b07a22480add648dd03?s=eyJ1IjogIjYxODEzNmZiZTBmNWU3MjNiYjk2MmE5MiJ9"
got, err := Args([]string{url})
if err != nil {
t.Fatalf("Args returned error: %v", err)
}
if len(got.Tracks) != 0 {
t.Fatalf("Args returned %d immediate tracks, want 0", len(got.Tracks))
}
if len(got.Pending) != 1 || got.Pending[0] != url {
t.Fatalf("Args pending = %#v, want [%q]", got.Pending, url)
}
}
func TestRemoteResolvesXiaoyuzhouEpisodeHTML(t *testing.T) {
const episodeURL = "https://www.xiaoyuzhoufm.com/episode/69a13b07a22480add648dd03?s=eyJ1IjogIjYxODEzNmZiZTBmNWU3MjNiYjk2MmE5MiJ9"
const audioURL = "https://media.xyzcdn.net/65d322815c5cc49b4db454a8/lqbqTgipk04QFSwIMACyGNK655rR.m4a"
const title = "周轶君对话张艾嘉:我从不刻意标榜“女性”"
const podcast = "山下声"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/episode/69a13b07a22480add648dd03" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.Error(w, "unexpected path", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<!DOCTYPE html>
<html><head>
<script name="schema:podcast-show" type="application/ld+json">{
"@context":"https://schema.org/",
"@type":"PodcastEpisode",
"url":"https://www.xiaoyuzhoufm.com/episode/69a13b07a22480add648dd03",
"name":"` + title + `",
"timeRequired":"PT106M",
"associatedMedia":{"@type":"MediaObject","contentUrl":"` + audioURL + `"},
"partOfSeries":{"@type":"PodcastSeries","name":"` + podcast + `","url":"https://www.xiaoyuzhoufm.com/podcast/65d322815c5cc49b4db454a8"}
}</script>
</head><body></body></html>`))
}))
defer srv.Close()
target, err := url.Parse(srv.URL)
if err != nil {
t.Fatalf("parsing test server URL: %v", err)
}
oldClient := httpClient
httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: rewriteHostTransport{target: target, rt: http.DefaultTransport},
}
defer func() {
httpClient = oldClient
}()
tracks, err := Remote([]string{episodeURL})
if err != nil {
t.Fatalf("Remote returned error: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("Remote returned %d tracks, want 1", len(tracks))
}
track := tracks[0]
if track.Path != audioURL {
t.Fatalf("track.Path = %q, want %q", track.Path, audioURL)
}
if track.Title != title {
t.Fatalf("track.Title = %q, want %q", track.Title, title)
}
if track.Artist != podcast {
t.Fatalf("track.Artist = %q, want %q", track.Artist, podcast)
}
if !track.Stream {
t.Fatalf("track.Stream = false, want true")
}
if track.DurationSecs != 106*60 {
t.Fatalf("track.DurationSecs = %d, want %d", track.DurationSecs, 106*60)
}
}
func TestParseXiaoyuzhouOgAudioTakesPrecedence(t *testing.T) {
const audioURL = "https://media.xyzcdn.net/audio.m4a"
const title = "Test Episode"
doc := `<!DOCTYPE html>
<html><head>
<meta property="og:audio" content="` + audioURL + `">
<meta property="og:title" content="` + title + `">
</head><body></body></html>`
track, err := parseXiaoyuzhouEpisodeHTML("https://www.xiaoyuzhoufm.com/episode/abc", doc)
if err != nil {
t.Fatalf("parseXiaoyuzhouEpisodeHTML returned error: %v", err)
}
if track.Path != audioURL {
t.Fatalf("track.Path = %q, want %q", track.Path, audioURL)
}
if track.Title != title {
t.Fatalf("track.Title = %q, want %q", track.Title, title)
}
}
func TestParseItunesDuration(t *testing.T) {
tests := []struct {
input string
want int
}{
// Plain seconds
{"3600", 3600},
{"90", 90},
{"0", 0},
// Fractional seconds
{"3661.5", 3661},
{"90.9", 90},
// MM:SS
{"1:30", 90},
{"87:05", 5225},
// HH:MM:SS
{"1:27:05", 5225},
{"0:01:30", 90},
// Whitespace
{" 3600 ", 3600},
// Empty
{"", 0},
// Invalid — return 0
{"abc", 0},
{"12:xx", 0},
{"1:2:xx", 0},
// Negative — clamp to 0
{"-1", 0},
{"0:-10", 0},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := parseItunesDuration(tt.input)
if got != tt.want {
t.Errorf("parseItunesDuration(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
type rewriteHostTransport struct {
target *url.URL
rt http.RoundTripper
}
func (t rewriteHostTransport) RoundTrip(req *http.Request) (*http.Response, error) {
clone := req.Clone(req.Context())
clone.URL.Scheme = t.target.Scheme
clone.URL.Host = t.target.Host
clone.Host = t.target.Host
return t.rt.RoundTrip(clone)
}
func TestIsHLSPlaylist(t *testing.T) {
master := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000\nchunklist_abc.m3u8\n"
media := "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:6\n#EXT-X-MEDIA-SEQUENCE:42\n#EXTINF:6.0,\nmedia_42.ts\n"
simple := "#EXTM3U\n#EXTINF:-1,Radio\nhttp://radio.example.com/stream\n"
if !isHLSPlaylist([]byte(master)) {
t.Error("master playlist should be detected as HLS")
}
if !isHLSPlaylist([]byte(media)) {
t.Error("media playlist should be detected as HLS")
}
if isHLSPlaylist([]byte(simple)) {
t.Error("plain radio M3U must NOT be detected as HLS")
}
}
func TestResolveM3U_HLS_ReturnsSingleStream(t *testing.T) {
const master = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000\nchunklist_abc.m3u8\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
_, _ = io.WriteString(w, master)
}))
defer srv.Close()
u := srv.URL + "/primary/gaucha_rbs.sdp/playlist.m3u8"
tracks, err := resolveM3U(u)
if err != nil {
t.Fatalf("resolveM3U: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("got %d tracks, want 1 (HLS = single stream)", len(tracks))
}
if tracks[0].Path != u {
t.Errorf("Path = %q, want original URL %q", tracks[0].Path, u)
}
if !tracks[0].Stream {
t.Error("Stream should be true")
}
if !tracks[0].Realtime {
t.Error("Realtime should be true (no #EXT-X-ENDLIST)")
}
}
func TestResolveM3U_PlainPlaylist_StillParsesTracks(t *testing.T) {
const pl = "#EXTM3U\n#EXTINF:-1,A\nhttp://x/a.mp3\n#EXTINF:-1,B\nhttp://x/b.mp3\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, pl)
}))
defer srv.Close()
tracks, err := resolveM3U(srv.URL + "/list.m3u")
if err != nil {
t.Fatalf("resolveM3U: %v", err)
}
if len(tracks) != 2 {
t.Fatalf("got %d tracks, want 2 (regression guard)", len(tracks))
}
}
+133
View File
@@ -0,0 +1,133 @@
package resolve
import (
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/bjarneo/cliamp/playlist"
)
var (
xiaoyuzhouSchemaScriptRe = regexp.MustCompile(`(?s)<script[^>]+name="schema:podcast-show"[^>]*>(.*?)</script>`)
isoDurationRe = regexp.MustCompile(`^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$`)
metaTagRe = regexp.MustCompile(`<meta\s[^>]*>`)
metaAttrRe = regexp.MustCompile(`(\w+)="([^"]+)"`)
)
func resolveXiaoyuzhouEpisode(pageURL string) ([]playlist.Track, error) {
resp, err := httpClient.Get(pageURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http status %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) // 2 MB cap
if err != nil {
return nil, fmt.Errorf("reading page: %w", err)
}
track, err := parseXiaoyuzhouEpisodeHTML(pageURL, string(body))
if err != nil {
return nil, err
}
return []playlist.Track{track}, nil
}
func parseXiaoyuzhouEpisodeHTML(pageURL, doc string) (playlist.Track, error) {
audioURL := extractMetaContent(doc, "property", "og:audio")
title := extractMetaContent(doc, "property", "og:title")
var schema struct {
Name string `json:"name"`
TimeRequired string `json:"timeRequired"`
AssociatedMedia struct {
ContentURL string `json:"contentUrl"`
} `json:"associatedMedia"`
PartOfSeries struct {
Name string `json:"name"`
} `json:"partOfSeries"`
}
if raw := extractXiaoyuzhouSchemaJSON(doc); raw != "" {
if err := json.Unmarshal([]byte(raw), &schema); err != nil && audioURL == "" {
return playlist.Track{}, fmt.Errorf("parsing schema.org JSON-LD: %w", err)
}
}
if audioURL == "" {
audioURL = schema.AssociatedMedia.ContentURL
}
if audioURL == "" {
return playlist.Track{}, fmt.Errorf("audio URL not found")
}
if title == "" {
title = schema.Name
}
if title == "" {
title = pageURL
}
return playlist.Track{
Path: audioURL,
Title: title,
Artist: schema.PartOfSeries.Name,
Stream: true,
DurationSecs: parseISODurationSeconds(schema.TimeRequired),
}, nil
}
func extractXiaoyuzhouSchemaJSON(doc string) string {
m := xiaoyuzhouSchemaScriptRe.FindStringSubmatch(doc)
if len(m) < 2 {
return ""
}
return html.UnescapeString(strings.TrimSpace(m[1]))
}
func extractMetaContent(doc, attr, value string) string {
for _, tag := range metaTagRe.FindAllString(doc, -1) {
attrs := make(map[string]string)
for _, m := range metaAttrRe.FindAllStringSubmatch(tag, -1) {
attrs[m[1]] = m[2]
}
if attrs[attr] == value {
if c := attrs["content"]; c != "" {
return html.UnescapeString(c)
}
}
}
return ""
}
func parseISODurationSeconds(raw string) int {
m := isoDurationRe.FindStringSubmatch(strings.TrimSpace(raw))
if len(m) == 0 {
return 0
}
var total int
if m[1] != "" {
if hours, err := strconv.Atoi(m[1]); err == nil {
total += hours * 3600
}
}
if m[2] != "" {
if minutes, err := strconv.Atoi(m[2]); err == nil {
total += minutes * 60
}
}
if m[3] != "" {
if seconds, err := strconv.Atoi(m[3]); err == nil {
total += seconds
}
}
return total
}