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

331 lines
8.9 KiB
Go

// Package radio implements a playlist.Provider for internet radio stations.
// It includes a built-in cliamp radio stream, user-defined stations from
// ~/.config/cliamp/radios.toml, favorites from radio_favorites.toml, and
// lazy-loaded catalog stations from the Radio Browser API.
package radio
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/tomlutil"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// Compile-time interface checks.
var (
_ provider.FavoriteToggler = (*Provider)(nil)
_ provider.CatalogLoader = (*Provider)(nil)
_ provider.CatalogSearcher = (*Provider)(nil)
_ provider.SectionedList = (*Provider)(nil)
)
const builtinName = "cliamp radio"
const builtinURL = "https://radio.cliamp.stream/streams.m3u"
// Provider serves radio stations as single-track playlists.
// It combines local stations, user favorites, and catalog stations
// from the Radio Browser API into a single unified list.
type Provider struct {
mu sync.Mutex
stations []station // built-in + user-defined (radios.toml)
favorites *Favorites // user favorites (radio_favorites.toml)
catalog []CatalogStation // lazily loaded from Radio Browser API
searchResults []CatalogStation // non-nil when API search is active
}
type station struct {
name string
url string
}
// New creates a Provider with the built-in station plus any user-defined
// stations from ~/.config/cliamp/radios.toml and favorites.
func New() *Provider {
p := &Provider{
stations: []station{
{name: builtinName, url: builtinURL},
},
}
dir, err := appdir.Dir()
if err != nil {
p.favorites = &Favorites{byURL: make(map[string]struct{})}
return p
}
if extra, err := loadStations(filepath.Join(dir, "radios.toml")); err == nil {
p.stations = append(p.stations, extra...)
}
p.favorites = LoadFavorites()
return p
}
func (p *Provider) Name() string { return "Radio" }
// Playlists returns a unified list: local stations, then favorites (★ prefixed),
// then catalog stations (with metadata). IDs are prefixed: "l:", "f:", "c:".
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
p.mu.Lock()
defer p.mu.Unlock()
var out []playlist.PlaylistInfo
// When search is active, show only search results.
if p.searchResults != nil {
for i, s := range p.searchResults {
out = append(out, p.catalogEntry("s", i, s))
}
return out, nil
}
// Local stations.
for i, s := range p.stations {
out = append(out, playlist.PlaylistInfo{
ID: fmt.Sprintf("l:%d", i),
Name: s.name,
})
}
// Favorites.
for i, s := range p.favorites.Stations() {
out = append(out, playlist.PlaylistInfo{
ID: fmt.Sprintf("f:%d", i),
Name: "★ " + formatCatalogName(s),
})
}
// Catalog stations.
for i, s := range p.catalog {
out = append(out, p.catalogEntry("c", i, s))
}
return out, nil
}
// catalogEntry builds a PlaylistInfo for a CatalogStation, marking favorites with ★.
func (p *Provider) catalogEntry(prefix string, idx int, s CatalogStation) playlist.PlaylistInfo {
name := formatCatalogName(s)
if p.favorites.Contains(s.URL) {
name = "★ " + name
}
return playlist.PlaylistInfo{
ID: fmt.Sprintf("%s:%d", prefix, idx),
Name: name,
}
}
// Tracks returns a single-track playlist for the given station ID.
func (p *Provider) Tracks(id string) ([]playlist.Track, error) {
p.mu.Lock()
defer p.mu.Unlock()
prefix, idx, err := parseStationID(id)
if err != nil {
return nil, err
}
var url, title string
switch prefix {
case "l":
if idx < 0 || idx >= len(p.stations) {
return nil, errors.New("invalid local station index")
}
url, title = p.stations[idx].url, p.stations[idx].name
case "f":
favs := p.favorites.Stations()
if idx < 0 || idx >= len(favs) {
return nil, errors.New("invalid favorite index")
}
url, title = favs[idx].URL, favs[idx].Name
case "c":
if idx < 0 || idx >= len(p.catalog) {
return nil, errors.New("invalid catalog station index")
}
url, title = p.catalog[idx].URL, p.catalog[idx].Name
case "s":
if p.searchResults == nil || idx < 0 || idx >= len(p.searchResults) {
return nil, errors.New("invalid search result index")
}
url, title = p.searchResults[idx].URL, p.searchResults[idx].Name
default:
return nil, errors.New("unknown station type")
}
return []playlist.Track{{
Path: url, Title: title, Stream: true, Realtime: true,
}}, nil
}
// AppendCatalog adds catalog stations fetched from the Radio Browser API.
func (p *Provider) AppendCatalog(stations []CatalogStation) {
p.mu.Lock()
defer p.mu.Unlock()
p.catalog = append(p.catalog, stations...)
}
// ToggleFavorite toggles the favorite status of a catalog or favorite entry.
// Returns (true, name) if added, (false, name) if removed.
func (p *Provider) ToggleFavorite(id string) (added bool, name string, err error) {
p.mu.Lock()
defer p.mu.Unlock()
prefix, idx, err := parseStationID(id)
if err != nil {
return false, "", err
}
var s CatalogStation
switch prefix {
case "c":
if idx < 0 || idx >= len(p.catalog) {
return false, "", errors.New("invalid catalog index")
}
s = p.catalog[idx]
case "s":
if p.searchResults == nil || idx < 0 || idx >= len(p.searchResults) {
return false, "", errors.New("invalid search result index")
}
s = p.searchResults[idx]
case "f":
favs := p.favorites.Stations()
if idx < 0 || idx >= len(favs) {
return false, "", errors.New("invalid favorite index")
}
s = favs[idx]
default:
return false, "", errors.New("cannot favorite local stations")
}
if p.favorites.Contains(s.URL) {
return false, s.Name, p.favorites.Remove(s.URL)
}
return true, s.Name, p.favorites.Add(s)
}
// SetSearchResults activates search mode with the given results.
// Playlists() will return search results instead of catalog stations.
func (p *Provider) SetSearchResults(stations []CatalogStation) {
p.mu.Lock()
defer p.mu.Unlock()
p.searchResults = stations
}
// ClearSearch deactivates search mode, restoring the catalog view.
func (p *Provider) ClearSearch() {
p.mu.Lock()
defer p.mu.Unlock()
p.searchResults = nil
}
// IsSearching returns true if API search results are active.
func (p *Provider) IsSearching() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.searchResults != nil
}
// LoadCatalogPage fetches the next page of catalog entries from the Radio
// Browser API and appends them to the provider's catalog.
// Implements provider.CatalogLoader.
func (p *Provider) LoadCatalogPage(offset, limit int) (int, error) {
stations, err := TopStationsOffset(offset, limit)
if err != nil {
return 0, err
}
p.AppendCatalog(stations)
return len(stations), nil
}
// SearchCatalog performs a server-side station search via the Radio Browser API.
// Results are reflected in subsequent Playlists() calls.
// Implements provider.CatalogSearcher.
func (p *Provider) SearchCatalog(query string) (int, error) {
stations, err := SearchStations(query, 200)
if err != nil {
return 0, err
}
p.SetSearchResults(stations)
return len(stations), nil
}
// IsFavoritableID reports whether the given ID can be favorited.
// Implements provider.SectionedList.
func (p *Provider) IsFavoritableID(id string) bool {
return IsCatalogOrFavID(id)
}
// IsCatalogOrFavID returns true if the ID belongs to a catalog, search, or favorite entry.
func IsCatalogOrFavID(id string) bool {
return strings.HasPrefix(id, "c:") || strings.HasPrefix(id, "f:") || strings.HasPrefix(id, "s:")
}
// IDPrefix returns the type prefix of a provider list ID ("l", "f", "c", or "").
// Also implements provider.SectionedList when called as a method.
func (p *Provider) IDPrefix(id string) string {
return idPrefix(id)
}
func idPrefix(id string) string {
prefix, _, ok := strings.Cut(id, ":")
if !ok {
return ""
}
return prefix
}
// parseStationID splits a prefixed ID like "c:42" into its prefix and index.
// Legacy numeric IDs (no colon) are treated as "l:" local station indices.
func parseStationID(id string) (prefix string, idx int, err error) {
raw := id
prefix, idxStr, ok := strings.Cut(id, ":")
if !ok {
prefix = "l"
idxStr = raw
}
idx, err = strconv.Atoi(idxStr)
if err != nil {
return "", 0, errors.New("invalid station ID")
}
return prefix, idx, nil
}
// formatCatalogName builds a display name from a CatalogStation.
func formatCatalogName(s CatalogStation) string {
name := s.Name
if s.Bitrate > 0 {
name += fmt.Sprintf(" [%dk]", s.Bitrate)
}
if s.Country != "" {
name += " · " + s.Country
}
return name
}
// loadStations parses a TOML file with [[station]] sections.
func loadStations(path string) ([]station, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, err
}
var stations []station
tomlutil.ParseSections(data, "station", func(f map[string]string) {
s := station{name: f["name"], url: f["url"]}
if s.name != "" && s.url != "" {
stations = append(stations, s)
}
})
return stations, nil
}