项目文件夹

文件
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

203 行
5.9 KiB
Go

// Package radiometa pulls now-playing metadata for radio stations that do not
// carry inline ICY StreamTitle metadata and instead publish the current track
// (or show) via a separate JSON API. It currently covers NTS and FIP.
//
// Resolver matches a stream URL to a fetch function; the player polls it and
// feeds titles through the same path as ICY metadata, so no display code needs
// to know where the title came from.
package radiometa
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
var client = &http.Client{Timeout: 8 * time.Second}
const userAgent = "cliamp/1.0 (https://github.com/bjarneo/cliamp)"
// Resolver reports how to fetch now-playing metadata for streamURL, or ok=false
// when the URL is not a recognized broadcaster. It satisfies
// player.StreamMetadataResolver.
func Resolver(streamURL string) (fetch func(ctx context.Context) (string, error), interval time.Duration, ok bool) {
u := strings.ToLower(streamURL)
switch {
case matchNTS(u):
channel := ntsChannel(u)
return func(ctx context.Context) (string, error) {
return ntsNowPlaying(ctx, channel)
}, 30 * time.Second, true
case matchFIP(u):
station := fipStationID(u)
return func(ctx context.Context) (string, error) {
return fipNowPlaying(ctx, station, time.Now().Unix())
}, 15 * time.Second, true
}
return nil, 0, false
}
func getJSON(ctx context.Context, url string, v any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(v)
}
// --- NTS -------------------------------------------------------------------
// matchNTS reports whether url is an NTS Radio stream. The directory entry
// points at ntslive.net (which 302-redirects to radiomast), so match either.
func matchNTS(url string) bool {
return strings.Contains(url, "ntslive.net") ||
strings.Contains(url, "radiomast.io/nts") ||
strings.Contains(url, "/nts1") || strings.Contains(url, "/nts2")
}
// ntsChannel returns the NTS channel ("1" or "2") encoded in the stream URL.
// The channel-2 stream is "stream2" (ntslive) or "nts2" (radiomast).
func ntsChannel(url string) string {
if strings.Contains(url, "stream2") || strings.Contains(url, "nts2") {
return "2"
}
return "1"
}
type ntsLive struct {
Results []struct {
Channel string `json:"channel_name"`
Now struct {
BroadcastTitle string `json:"broadcast_title"`
Embeds struct {
Details struct {
Name string `json:"name"`
} `json:"details"`
} `json:"embeds"`
} `json:"now"`
} `json:"results"`
}
func ntsNowPlaying(ctx context.Context, channel string) (string, error) {
var live ntsLive
if err := getJSON(ctx, "https://www.nts.live/api/v2/live", &live); err != nil {
return "", err
}
return parseNTS(&live, channel), nil
}
// parseNTS returns the current show title for the given channel. NTS is live DJ
// radio with no per-track tagging, so the show/broadcast name is the best
// available now-playing string. Prefers the nicely-cased details name.
func parseNTS(live *ntsLive, channel string) string {
for _, r := range live.Results {
if r.Channel != channel {
continue
}
if name := strings.TrimSpace(r.Now.Embeds.Details.Name); name != "" {
return name
}
return strings.TrimSpace(r.Now.BroadcastTitle)
}
return ""
}
// --- FIP (Radio France) ----------------------------------------------------
// fipStations maps a URL slug fragment to the Radio France livemeta station ID.
// Order matters: specific genres are checked before the plain FIP default.
var fipStations = []struct {
slug string
id int
}{
{"fipjazz", 65},
{"fipgroove", 66},
{"fiprock", 64},
{"fipreggae", 71},
{"fipelectro", 74},
{"fipmetal", 77},
{"fipnouveau", 70}, // fipnouveautes
{"fipworld", 69},
{"fipmonde", 69},
}
// matchFIP reports whether url is a Radio France FIP stream.
func matchFIP(url string) bool {
return strings.Contains(url, "radiofrance.fr") && strings.Contains(url, "fip")
}
// fipStationID returns the livemeta station ID for a FIP stream URL, defaulting
// to 7 (the main FIP channel) when no sub-channel slug matches.
func fipStationID(url string) int {
for _, s := range fipStations {
if strings.Contains(url, s.slug) {
return s.id
}
}
return 7
}
type fipLive struct {
Steps map[string]struct {
Title string `json:"title"`
Authors string `json:"authors"`
Start int64 `json:"start"`
End int64 `json:"end"`
} `json:"steps"`
}
func fipNowPlaying(ctx context.Context, station int, now int64) (string, error) {
var live fipLive
url := fmt.Sprintf("https://api.radiofrance.fr/livemeta/pull/%d", station)
if err := getJSON(ctx, url, &live); err != nil {
return "", err
}
return parseFIP(&live, now), nil
}
// parseFIP returns "Artist - Title" for the step playing at now. It picks the
// step whose [start,end) window contains now; if none does (clock skew, gaps),
// it falls back to the most recent step that has already started.
func parseFIP(live *fipLive, now int64) string {
var bestStart int64 = -1
var artist, title string
for _, s := range live.Steps {
if s.Start <= now && now < s.End {
return formatTrack(s.Authors, s.Title)
}
if s.Start <= now && s.Start > bestStart {
bestStart = s.Start
artist, title = s.Authors, s.Title
}
}
return formatTrack(artist, title)
}
// formatTrack joins artist and title as "Artist - Title", matching the ICY
// StreamTitle convention the UI splits on. Falls back gracefully when either
// field is empty.
func formatTrack(artist, title string) string {
artist = strings.TrimSpace(artist)
title = strings.TrimSpace(title)
switch {
case artist != "" && title != "":
return artist + " - " + title
case title != "":
return title
default:
return artist
}
}