mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
feat(tunein): play stations/episodes/programs via cli source tunein (#226)
Add a `soundtouch-cli source tunein` subcommand that takes a TuneIn
guide ID and routes it through the right SelectContentItem shape —
`--station`, `--episode`, `--program`, or `--id` with prefix
auto-detect. The flag picks the ContentItem Type (`stationurl` for
stations/episodes, `tracklisturl` for programs) and the location
template, then enriches the now-playing metadata from TuneIn's describe
endpoint unless `--no-lookup` is set.
Program IDs (`p<N>`) are containers, not streams. The legacy OPML
`Tune.ashx?id=p<N>` returns `#STATUS: 400`, which pre-filter went out
to the speaker verbatim. Fix in three layers:
1. `parseTuneInStreamBody` filters `#`-prefixed comment lines out of
Tune.ashx responses and errors when nothing playable remains, so
a broken TuneIn reply surfaces as a real 500 instead of corrupting
the playback response.
2. `TuneInPlaybackPodcast` expands `p<N>` to its newest episode via
`api.radiotime.com/profiles/{id}/contents` (same JSON shape as
api.tunein.com; uses the radiotime mirror so all program traffic
stays on the host already in `allowedTuneInHosts`).
3. `tuneInSearchProfile` (Program search items) and
`TuneInNavigateProfile` (program detail hero) now emit
`BmxPlayback` links, so soundtouch-web renders play buttons on
program cards and on the profile hero — clicking either plays the
latest episode via the same backend expansion.
Tests pin the parser contracts (`#STATUS: 400` filter, program-contents
episode pick) and the navigate Program-only playback emission. CLI
resolver has table-driven coverage for kind selection, prefix
auto-detect, and conflicting-flag errors.
Endpoint contract + raw probe responses captured under
`_/i226/tunein-api-findings.md` and `_/i226/tunein-probe/` for future
reference.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4507d82b4c
commit
cc7675a07c
@@ -0,0 +1,152 @@
|
||||
// Package main — `soundtouch-cli source tunein` subcommand.
|
||||
//
|
||||
// Convenience shortcut for the verbose `source content --source TUNEIN
|
||||
// --type … --location …` pattern. Picks the right Type + location template
|
||||
// from the TuneIn guide-ID prefix, optionally fetches name + artwork from
|
||||
// TuneIn's describe endpoint, then calls the same SelectContentItem path
|
||||
// the generic `source content` command uses.
|
||||
//
|
||||
// Implements #226.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// tuneInKind captures the three guide-ID shapes the SoundTouch firmware
|
||||
// distinguishes; each picks a different Bose `/v1/playback/...` location
|
||||
// template and a different ContentItem Type.
|
||||
type tuneInKind struct {
|
||||
flag string // CLI flag name (`station`, `episode`, `program`)
|
||||
prefix string // single-letter guide-ID prefix (`s`, `e`, `p`)
|
||||
location string // printf template, %s = guide ID
|
||||
itemType string // ContentItem.Type the speaker expects
|
||||
humanName string // user-facing kind label for log lines
|
||||
}
|
||||
|
||||
var tuneInKinds = []tuneInKind{
|
||||
{flag: "station", prefix: "s", location: "/v1/playback/station/%s", itemType: "stationurl", humanName: "live station"},
|
||||
{flag: "episode", prefix: "e", location: "/v1/playback/episode/%s", itemType: "stationurl", humanName: "podcast episode"},
|
||||
{flag: "program", prefix: "p", location: "/v1/playback/episodes/%s", itemType: "tracklisturl", humanName: "podcast program"},
|
||||
}
|
||||
|
||||
// resolveTuneInKind picks a kind from the CLI flags. Exactly one of
|
||||
// --station / --episode / --program must be set, OR --id with a prefix we
|
||||
// recognise. Returns the kind plus the bare guide ID.
|
||||
func resolveTuneInKind(c *cli.Context) (*tuneInKind, string, error) {
|
||||
// Explicit kind flags take precedence over --id.
|
||||
var picked *tuneInKind
|
||||
|
||||
var id string
|
||||
|
||||
for i, k := range tuneInKinds {
|
||||
v := c.String(k.flag)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if picked != nil {
|
||||
return nil, "", fmt.Errorf("only one of --station, --episode, --program may be set")
|
||||
}
|
||||
|
||||
picked = &tuneInKinds[i]
|
||||
id = v
|
||||
}
|
||||
|
||||
if picked != nil {
|
||||
return picked, strings.TrimSpace(id), nil
|
||||
}
|
||||
|
||||
// Fall back to --id with prefix auto-detect.
|
||||
raw := strings.TrimSpace(c.String("id"))
|
||||
if raw == "" {
|
||||
return nil, "", fmt.Errorf("one of --station, --episode, --program, or --id is required")
|
||||
}
|
||||
|
||||
if raw == "" {
|
||||
return nil, "", fmt.Errorf("--id is empty")
|
||||
}
|
||||
|
||||
for i, k := range tuneInKinds {
|
||||
if strings.HasPrefix(raw, k.prefix) {
|
||||
return &tuneInKinds[i], raw, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("--id %q has no recognised TuneIn prefix; use --station/--episode/--program explicitly", raw)
|
||||
}
|
||||
|
||||
// playTuneIn is the action wired into `soundtouch-cli source tunein`.
|
||||
func playTuneIn(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kind, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := c.String("name")
|
||||
artwork := c.String("artwork")
|
||||
|
||||
// Optional metadata enrichment — only fetch if the user hasn't already
|
||||
// supplied both, and they haven't asked us to skip it.
|
||||
if !c.Bool("no-lookup") && (name == "" || artwork == "") {
|
||||
fetchedName, fetchedLogo, lookupErr := bmx.TuneInDescribeMeta(id)
|
||||
if lookupErr != nil {
|
||||
// Non-fatal: the speaker can resolve the title itself; just
|
||||
// note the failure so an operator sees what went wrong.
|
||||
fmt.Printf(" Note: TuneIn describe lookup failed (%v); proceeding without enrichment.\n", lookupErr)
|
||||
} else {
|
||||
if name == "" {
|
||||
name = fetchedName
|
||||
}
|
||||
|
||||
if artwork == "" {
|
||||
artwork = fetchedLogo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
// Fall back to a sensible non-empty default so the speaker's
|
||||
// now-playing UI doesn't show a blank source label.
|
||||
name = "TuneIn"
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: kind.itemType,
|
||||
Location: fmt.Sprintf(kind.location, id),
|
||||
ItemName: name,
|
||||
ContainerArt: artwork,
|
||||
IsPresetable: true,
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Playing TuneIn "+kind.humanName, clientConfig.Host, clientConfig.Port)
|
||||
fmt.Printf(" ID: %s\n", id)
|
||||
fmt.Printf(" Location: %s\n", contentItem.Location)
|
||||
fmt.Printf(" Type: %s\n", contentItem.Type)
|
||||
fmt.Printf(" Name: %s\n", contentItem.ItemName)
|
||||
|
||||
if contentItem.ContainerArt != "" {
|
||||
fmt.Printf(" Artwork: %s\n", contentItem.ContainerArt)
|
||||
}
|
||||
|
||||
if err := client.SelectContentItem(contentItem); err != nil {
|
||||
return fmt.Errorf("failed to select TuneIn content: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("TuneIn content selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// newCtx wires a *cli.Context with the kind-selection flags the resolver
|
||||
// reads, plus whatever values the test wants set. Empty-string values are
|
||||
// the default (flag not provided).
|
||||
func newCtx(t *testing.T, kv map[string]string) *cli.Context {
|
||||
t.Helper()
|
||||
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
for _, name := range []string{"station", "episode", "program", "id"} {
|
||||
fs.String(name, "", "")
|
||||
}
|
||||
|
||||
for k, v := range kv {
|
||||
if err := fs.Set(k, v); err != nil {
|
||||
t.Fatalf("fs.Set(%q, %q): %v", k, v, err)
|
||||
}
|
||||
}
|
||||
|
||||
return cli.NewContext(nil, fs, nil)
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Station(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"station": "s14991"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "station" || k.itemType != "stationurl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "s14991" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Episode(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"episode": "e789012"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "episode" || k.itemType != "stationurl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "e789012" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
|
||||
if !strings.Contains(k.location, "/v1/playback/episode/") {
|
||||
t.Errorf("wrong location template: %q", k.location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Program(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"program": "p123456"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "program" || k.itemType != "tracklisturl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "p123456" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
|
||||
if !strings.Contains(k.location, "/v1/playback/episodes/") {
|
||||
t.Errorf("wrong location template: %q", k.location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_IDPrefixAutoDetect(t *testing.T) {
|
||||
cases := []struct {
|
||||
id string
|
||||
wantFlag string
|
||||
}{
|
||||
{"s14991", "station"},
|
||||
{"e789012", "episode"},
|
||||
{"p123456", "program"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.id, func(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"id": tc.id})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != tc.wantFlag {
|
||||
t.Errorf("auto-detect picked %q; want %q", k.flag, tc.wantFlag)
|
||||
}
|
||||
|
||||
if id != tc.id {
|
||||
t.Errorf("id round-tripped wrong: got %q want %q", id, tc.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_NoFlags(t *testing.T) {
|
||||
c := newCtx(t, nil)
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no flags are set")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "required") {
|
||||
t.Errorf("error message should mention required flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_ConflictingFlags(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"station": "s14991", "episode": "e789012"})
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when conflicting flags are set")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "only one of") {
|
||||
t.Errorf("error message should mention exclusivity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_UnknownPrefix(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"id": "x999"})
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown ID prefix")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "no recognised TuneIn prefix") {
|
||||
t.Errorf("error message should explain prefix mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1054,6 +1054,43 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tunein",
|
||||
Usage: "Play a TuneIn station / episode / program by guide ID (#226)",
|
||||
Action: playTuneIn,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "station",
|
||||
Usage: "TuneIn live-station guide ID (e.g. s14991)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "episode",
|
||||
Usage: "TuneIn single-episode guide ID (e.g. e789012)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "program",
|
||||
Usage: "TuneIn podcast/program guide ID (e.g. p123456)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Usage: "TuneIn guide ID; kind auto-detected from s/e/p prefix",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Override the display name (skips name lookup)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Override the artwork URL (skips artwork lookup)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-lookup",
|
||||
Usage: "Skip the TuneIn describe lookup; send the bare ContentItem",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "availability",
|
||||
Usage: "Show service availability",
|
||||
|
||||
+258
-11
@@ -23,6 +23,17 @@ const (
|
||||
TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
|
||||
TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
|
||||
|
||||
// TuneInProfileContents is the modern JSON API that lists a
|
||||
// program's (`p<N>`) episodes. The legacy OPML endpoints can't —
|
||||
// `Tune.ashx?id=p<N>` returns `#STATUS: 400`, `Browse.ashx?id=p<N>`
|
||||
// only surfaces related genres + networks. Same payload is served
|
||||
// from api.tunein.com and api.radiotime.com; we use radiotime
|
||||
// because TuneInNavigateProfile already navigates there via
|
||||
// Pivots.Contents.Url, so all program-related traffic stays on the
|
||||
// same host that's already in allowedTuneInHosts. See
|
||||
// `_/i226/tunein-api-findings.md` for the full endpoint map.
|
||||
TuneInProfileContents = "https://api.radiotime.com/profiles/%s/contents?version=1.3"
|
||||
|
||||
// DefaultTuneInStreamFormats is the comma-separated format list
|
||||
// AfterTouch sends to TuneIn's Tune.ashx by default. Matches the
|
||||
// pre-2026-05-10 behaviour from before PR #249 added "hls"
|
||||
@@ -512,11 +523,26 @@ func tuneInSearchProfile(item map[string]interface{}, name string) models.BmxNav
|
||||
apiURL, _ := profile["Url"].(string)
|
||||
apiURLEncoded := base64.URLEncoding.EncodeToString([]byte(apiURL))
|
||||
|
||||
links := &models.Links{
|
||||
BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/profiles/%s/%s/%s", name, guideID, apiURLEncoded)},
|
||||
BmxPreset: &models.Link{ContainerArt: image, Href: fmt.Sprintf("/v1/preset/program/%s", guideID), Name: title, Type: "tracklisturl"},
|
||||
}
|
||||
|
||||
// Programs are containers, but with the `p` → `t` expansion in
|
||||
// TuneInPlaybackPodcast a single "play this program" click can now
|
||||
// route to the newest episode. Surface that as a BmxPlayback link so
|
||||
// the web UI renders a play button on the program card itself, not
|
||||
// just on individual episode cards reached by drilling in. Artists
|
||||
// stay navigate-only — there's no single sensible "play this artist"
|
||||
// stream.
|
||||
if name == "Program" && guideID != "" {
|
||||
encodedName := base64.URLEncoding.EncodeToString([]byte(title))
|
||||
playbackHref := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", guideID, encodedName)
|
||||
links.BmxPlayback = &models.Link{Href: playbackHref, Type: "tracklisturl"}
|
||||
}
|
||||
|
||||
return models.BmxNavItem{
|
||||
Links: &models.Links{
|
||||
BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/profiles/%s/%s/%s", name, guideID, apiURLEncoded)},
|
||||
BmxPreset: &models.Link{ContainerArt: image, Href: fmt.Sprintf("/v1/preset/program/%s", guideID), Name: title, Type: "tracklisturl"},
|
||||
},
|
||||
Links: links,
|
||||
ImageUrl: image,
|
||||
Name: title,
|
||||
Subtitle: subtitle,
|
||||
@@ -539,10 +565,26 @@ func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
|
||||
profileTitle, _ := profileItem["Title"].(string)
|
||||
profileImage, _ := profileItem["Image"].(string)
|
||||
profileSubtitle, _ := profileItem["Subtitle"].(string)
|
||||
profileType, _ := profileItem["Type"].(string)
|
||||
profileGuideID, _ := profileItem["GuideId"].(string)
|
||||
|
||||
heroItem := models.BmxNavItem{Name: profileTitle, ImageUrl: profileImage, Subtitle: profileSubtitle}
|
||||
|
||||
// Surface "play latest episode" on the profile hero so users don't
|
||||
// have to scroll to the episode list. Matches the BmxPlayback link
|
||||
// emitted for Program cards in search results; the backend
|
||||
// p<N> → t<N> expansion resolves the actual stream.
|
||||
if profileType == "Program" && profileGuideID != "" {
|
||||
encodedName := base64.URLEncoding.EncodeToString([]byte(profileTitle))
|
||||
playbackHref := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", profileGuideID, encodedName)
|
||||
heroItem.Links = &models.Links{
|
||||
BmxPlayback: &models.Link{Href: playbackHref, Type: "tracklisturl"},
|
||||
}
|
||||
}
|
||||
|
||||
sections := []models.BmxNavSection{
|
||||
{
|
||||
Items: []models.BmxNavItem{{Name: profileTitle, ImageUrl: profileImage, Subtitle: profileSubtitle}},
|
||||
Items: []models.BmxNavItem{heroItem},
|
||||
Layout: "hero",
|
||||
Name: "",
|
||||
},
|
||||
@@ -578,6 +620,194 @@ func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseTuneInStreamBody filters a Tune.ashx response body down to the
|
||||
// playable stream URLs. TuneIn responds with HTTP 200 even on errors,
|
||||
// embedding a `#STATUS: <code>` comment line in the body (the body is
|
||||
// pls/m3u-like, so `#`-prefixed lines are comments — including error
|
||||
// markers like `#STATUS: 400`). Without this filter the caller would
|
||||
// happily pass `#STATUS: 400` to the speaker as if it were a stream URL.
|
||||
//
|
||||
// Returns the cleaned list of URL strings (TrimSpaced, comment lines
|
||||
// dropped, empty lines dropped). Returns an error if no playable URL
|
||||
// remains so callers surface a real 500 instead of silently corrupting
|
||||
// the playback response.
|
||||
func parseTuneInStreamBody(body []byte, guideID string) ([]string, error) {
|
||||
raw := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||
out := make([]string, 0, len(raw))
|
||||
|
||||
for _, line := range raw {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, line)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("TuneIn returned no playable stream URL for guide-id %q (body: %q)",
|
||||
guideID, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// tuneInProfileContentsResponse models the subset of the
|
||||
// api.tunein.com/profiles/{id}/contents JSON we need to pick a
|
||||
// program's newest episode. The endpoint returns substantially more
|
||||
// fields per item; everything outside this struct is ignored.
|
||||
type tuneInProfileContentsResponse struct {
|
||||
Items []tuneInProfileContentsItem `json:"Items"`
|
||||
}
|
||||
|
||||
type tuneInProfileContentsItem struct {
|
||||
ContainerType string `json:"ContainerType"`
|
||||
Title string `json:"Title"`
|
||||
AccessibilityTitle string `json:"AccessibilityTitle"`
|
||||
Children []tuneInProfileContentsTopic `json:"Children"`
|
||||
}
|
||||
|
||||
type tuneInProfileContentsTopic struct {
|
||||
GuideId string `json:"GuideId"`
|
||||
Type string `json:"Type"`
|
||||
Title string `json:"Title"`
|
||||
Image string `json:"Image"`
|
||||
}
|
||||
|
||||
// parseTuneInProgramContents walks a profile/contents JSON body and
|
||||
// returns the guide-id of the newest playable episode. The contract:
|
||||
//
|
||||
// - Items[] entry with ContainerType=="Topics" and Title (or
|
||||
// AccessibilityTitle) equal to "Episodes" is treated as the
|
||||
// authoritative episode list.
|
||||
// - If no item matches by name, the first ContainerType=="Topics"
|
||||
// entry is used as fallback — TuneIn occasionally varies the
|
||||
// localised title.
|
||||
// - Inside the chosen container the first child with a `t`-prefixed
|
||||
// GuideId wins. TuneIn orders children newest-first.
|
||||
//
|
||||
// Returns a wrapped error if the body is malformed or contains no
|
||||
// playable topic; callers surface this as a 500 rather than handing
|
||||
// the speaker a broken stream URL.
|
||||
func parseTuneInProgramContents(body []byte, programID string) (episodeID string, err error) {
|
||||
var parsed tuneInProfileContentsResponse
|
||||
if decErr := json.Unmarshal(body, &parsed); decErr != nil {
|
||||
return "", fmt.Errorf("decode TuneIn profile/contents for %q: %w", programID, decErr)
|
||||
}
|
||||
|
||||
var fallback *tuneInProfileContentsItem
|
||||
|
||||
for i := range parsed.Items {
|
||||
item := &parsed.Items[i]
|
||||
if item.ContainerType != "Topics" {
|
||||
continue
|
||||
}
|
||||
|
||||
if fallback == nil {
|
||||
fallback = item
|
||||
}
|
||||
|
||||
if strings.EqualFold(item.Title, "Episodes") ||
|
||||
strings.EqualFold(item.AccessibilityTitle, "Episodes") {
|
||||
if id := firstTuneInTopicGuideID(item.Children); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fallback != nil {
|
||||
if id := firstTuneInTopicGuideID(fallback.Children); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no playable episode found in TuneIn profile/contents for program %q", programID)
|
||||
}
|
||||
|
||||
func firstTuneInTopicGuideID(children []tuneInProfileContentsTopic) string {
|
||||
for _, child := range children {
|
||||
if strings.HasPrefix(child.GuideId, "t") {
|
||||
return child.GuideId
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveTuneInProgramLatestEpisode fetches the program's profile from
|
||||
// api.tunein.com and returns the newest playable episode's topic
|
||||
// guide-id (the `t<N>` form Tune.ashx accepts). The legacy OPML
|
||||
// endpoints can't enumerate program episodes; see
|
||||
// `_/i226/tunein-api-findings.md` for the full endpoint contract.
|
||||
func resolveTuneInProgramLatestEpisode(programID string) (episodeID string, err error) {
|
||||
contentsURL := fmt.Sprintf(TuneInProfileContents, programID)
|
||||
|
||||
resp, err := http.Get(contentsURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TuneIn profile/contents returned status %d for program %q",
|
||||
resp.StatusCode, programID)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return parseTuneInProgramContents(body, programID)
|
||||
}
|
||||
|
||||
// TuneInDescribeMeta fetches just the display name and logo URL for a TuneIn
|
||||
// guide ID via the same describe endpoint TuneInPlayback uses. Useful for
|
||||
// CLI / UI enrichment that wants to populate ContentItem.ItemName +
|
||||
// ContainerArt before sending a SelectContentItem to the speaker — without
|
||||
// resolving the full stream URL.
|
||||
//
|
||||
// Returns empty strings (and a nil error) if the describe payload doesn't
|
||||
// contain a recognisable station / show element. Network errors and XML
|
||||
// decode errors surface verbatim.
|
||||
func TuneInDescribeMeta(id string) (name, logo string, err error) {
|
||||
describeURL := fmt.Sprintf(TuneInDescribe, id)
|
||||
|
||||
resp, err := http.Get(describeURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Same shape TuneInPlayback parses for stations. For programs and
|
||||
// episodes the describe endpoint returns analogous structures; if
|
||||
// the station element is absent the response yields empty strings
|
||||
// and the caller can fall back to user-supplied values.
|
||||
var opml struct {
|
||||
Body struct {
|
||||
Outline struct {
|
||||
Station struct {
|
||||
Name string `xml:"name"`
|
||||
Logo string `xml:"logo"`
|
||||
} `xml:"station"`
|
||||
} `xml:"outline"`
|
||||
} `xml:"body"`
|
||||
}
|
||||
|
||||
if uErr := xml.Unmarshal(body, &opml); uErr != nil {
|
||||
return "", "", uErr
|
||||
}
|
||||
|
||||
return opml.Body.Outline.Station.Name, opml.Body.Outline.Station.Logo, nil
|
||||
}
|
||||
|
||||
// TuneInPlayback resolves a live radio station and returns a Bose-compatible
|
||||
// playback response with primary stream and variants. formats is the
|
||||
// comma-separated list passed to Tune.ashx?formats=… ; empty falls back to
|
||||
@@ -628,9 +858,9 @@ func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamURLList := strings.Split(strings.TrimSpace(string(streamBody)), "\n")
|
||||
if len(streamURLList) == 0 || streamURLList[0] == "" {
|
||||
return nil, fmt.Errorf("no streams found")
|
||||
streamURLList, err := parseTuneInStreamBody(streamBody, stationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamID := "e3342"
|
||||
@@ -725,7 +955,24 @@ func TuneInPodcastInfo(podcastID, encodedName string) (*models.BmxPodcastInfoRes
|
||||
// TuneInPlaybackPodcast resolves an on-demand podcast episode and returns
|
||||
// a playback response suitable for SoundTouch devices. formats has the
|
||||
// same semantics as in TuneInPlayback.
|
||||
//
|
||||
// Accepts three TuneIn guide-id shapes:
|
||||
// - `t<N>` — topic/episode; played directly.
|
||||
// - `e<N>` — live episode; played directly.
|
||||
// - `p<N>` — podcast program (a container, not a stream). Expanded
|
||||
// to its newest episode via the JSON profile/contents API before
|
||||
// resolving the stream URL. The legacy OPML `Tune.ashx?id=p<N>`
|
||||
// would return `#STATUS: 400` for this case.
|
||||
func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackResponse, error) {
|
||||
if strings.HasPrefix(podcastID, "p") {
|
||||
episodeID, resolveErr := resolveTuneInProgramLatestEpisode(podcastID)
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
}
|
||||
|
||||
podcastID = episodeID
|
||||
}
|
||||
|
||||
describeURL := fmt.Sprintf(TuneInDescribe, podcastID)
|
||||
|
||||
resp, err := http.Get(describeURL)
|
||||
@@ -774,9 +1021,9 @@ func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackRespon
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamURLList := strings.Split(strings.TrimSpace(string(streamBody)), "\n")
|
||||
if len(streamURLList) == 0 || streamURLList[0] == "" {
|
||||
return nil, fmt.Errorf("no streams found")
|
||||
streamURLList, err := parseTuneInStreamBody(streamBody, podcastID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamID := "e3342"
|
||||
|
||||
@@ -270,3 +270,265 @@ func TestTuneInStream_OverrideHonoured(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTuneInStreamBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantURLs []string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "single URL",
|
||||
body: "https://stream.example.com/foo.mp3\n",
|
||||
wantURLs: []string{"https://stream.example.com/foo.mp3"},
|
||||
},
|
||||
{
|
||||
name: "multiple URLs",
|
||||
body: "https://a/1.mp3\nhttps://b/2.mp3\n",
|
||||
wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
|
||||
},
|
||||
{
|
||||
// The bug behind PR #313's i314 follow-up — TuneIn 200's the
|
||||
// response body with `#STATUS: 400` for guide-ids that aren't
|
||||
// streamable (e.g. podcast program IDs sent to Tune.ashx).
|
||||
// Pre-fix, this string went out to the speaker as if it were a
|
||||
// stream URL.
|
||||
name: "comment-only body — TuneIn 400 error",
|
||||
body: "#STATUS: 400\n#description=Bad request\n",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "comments mixed with real URL",
|
||||
body: "#EXTM3U\nhttps://stream.example.com/foo.mp3\n#END\n",
|
||||
wantURLs: []string{"https://stream.example.com/foo.mp3"},
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: "",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "only blank lines",
|
||||
body: "\n\n \n",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "trims surrounding whitespace per line",
|
||||
body: " https://a/1.mp3 \n\thttps://b/2.mp3\t\n",
|
||||
wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseTuneInStreamBody([]byte(tc.body), "test-guide-id")
|
||||
|
||||
if tc.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %v", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "test-guide-id") {
|
||||
t.Errorf("error should mention the guide-id for diagnosis: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != len(tc.wantURLs) {
|
||||
t.Fatalf("len mismatch: got %d (%v), want %d (%v)", len(got), got, len(tc.wantURLs), tc.wantURLs)
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i] != tc.wantURLs[i] {
|
||||
t.Errorf("URL[%d] mismatch: got %q, want %q", i, got[i], tc.wantURLs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuneInSearchProfileEmitsBmxPlayback pins the rule that
|
||||
// program-card play buttons appear in the web/CLI search UI: Program
|
||||
// search items get a BmxPlayback link (so the speaker hits our
|
||||
// podcast endpoint and the p<N> → t<N> expansion kicks in), while
|
||||
// Artist items stay navigate-only — there's no single sensible
|
||||
// stream for an artist.
|
||||
func TestTuneInSearchProfileEmitsBmxPlayback(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
profileName string
|
||||
guideID string
|
||||
wantPlayback bool
|
||||
wantType string
|
||||
}{
|
||||
{name: "Program with guide-id gets play link", profileName: "Program", guideID: "p290778", wantPlayback: true, wantType: "tracklisturl"},
|
||||
{name: "Artist with guide-id is navigate-only", profileName: "Artist", guideID: "a12345", wantPlayback: false},
|
||||
{name: "Program without guide-id is navigate-only", profileName: "Program", guideID: "", wantPlayback: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
item := map[string]interface{}{
|
||||
"GuideId": tc.guideID,
|
||||
"Title": "Die Nachrichten",
|
||||
"Image": "http://example.com/logo.png",
|
||||
"Subtitle": "Deutschlandfunk",
|
||||
"Actions": map[string]interface{}{
|
||||
"Profile": map[string]interface{}{
|
||||
"Url": "https://api.radiotime.com/profiles/" + tc.guideID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
navItem := tuneInSearchProfile(item, tc.profileName)
|
||||
|
||||
if navItem.Links == nil {
|
||||
t.Fatal("expected Links to be set")
|
||||
}
|
||||
|
||||
if tc.wantPlayback {
|
||||
if navItem.Links.BmxPlayback == nil {
|
||||
t.Fatal("expected BmxPlayback link for Program")
|
||||
}
|
||||
|
||||
if navItem.Links.BmxPlayback.Type != tc.wantType {
|
||||
t.Errorf("BmxPlayback.Type = %q, want %q", navItem.Links.BmxPlayback.Type, tc.wantType)
|
||||
}
|
||||
|
||||
if !strings.Contains(navItem.Links.BmxPlayback.Href, tc.guideID) {
|
||||
t.Errorf("BmxPlayback.Href must carry the guide-id %q; got %q", tc.guideID, navItem.Links.BmxPlayback.Href)
|
||||
}
|
||||
|
||||
if !strings.Contains(navItem.Links.BmxPlayback.Href, "encoded_name=") {
|
||||
t.Errorf("BmxPlayback.Href should carry encoded_name; got %q", navItem.Links.BmxPlayback.Href)
|
||||
}
|
||||
} else if navItem.Links.BmxPlayback != nil {
|
||||
t.Errorf("did not expect BmxPlayback link; got %+v", navItem.Links.BmxPlayback)
|
||||
}
|
||||
|
||||
// Navigation drill-in must always remain available, even when
|
||||
// a play button is emitted — clicking the card body should
|
||||
// still take the user to the episode list.
|
||||
if navItem.Links.BmxNavigate == nil {
|
||||
t.Error("expected BmxNavigate link to remain available")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTuneInProgramContents pins the contract behind the
|
||||
// p<N> → t<N> expansion that powers `--program` playback for issue
|
||||
// #226. Real-world fixture shape captured from
|
||||
// api.tunein.com/profiles/p290778/contents (see
|
||||
// `_/i226/tunein-probe/profile_contents.json`).
|
||||
func TestParseTuneInProgramContents(t *testing.T) {
|
||||
const happyPath = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Episodes",
|
||||
"Children": [
|
||||
{ "GuideId": "t554138374", "Type": "Topic", "Title": "newest" },
|
||||
{ "GuideId": "t554134863", "Type": "Topic", "Title": "previous" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// TuneIn varies the localised container title; verify the
|
||||
// fallback picks the first Topics container even when the title
|
||||
// doesn't match "Episodes".
|
||||
const localisedTitle = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Folgen",
|
||||
"Children": [
|
||||
{ "GuideId": "t111", "Type": "Topic", "Title": "newest" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// "Episodes" container precedence: even if a "Related Shows"
|
||||
// Topics container appears first, we must pick the named one.
|
||||
const episodesAfterRelated = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Related Shows",
|
||||
"Children": [
|
||||
{ "GuideId": "t999", "Type": "Topic", "Title": "wrong" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Episodes",
|
||||
"Children": [
|
||||
{ "GuideId": "t222", "Type": "Topic", "Title": "right" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// Skip non-topic children — TuneIn occasionally mixes in
|
||||
// container-style children (rare, but defensive).
|
||||
const skipsNonTopic = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Episodes",
|
||||
"Children": [
|
||||
{ "GuideId": "p333", "Type": "Container", "Title": "nested program" },
|
||||
{ "GuideId": "t444", "Type": "Topic", "Title": "real episode" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantID string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "happy path — first child wins", body: happyPath, wantID: "t554138374"},
|
||||
{name: "localised title — falls back to first Topics container", body: localisedTitle, wantID: "t111"},
|
||||
{name: "Episodes container preferred over Related", body: episodesAfterRelated, wantID: "t222"},
|
||||
{name: "skips non-Topic children", body: skipsNonTopic, wantID: "t444"},
|
||||
{name: "empty body — error", body: `{}`, wantError: true},
|
||||
{name: "no Topics containers — error", body: `{"Items":[{"ContainerType":"Banner","Children":[]}]}`, wantError: true},
|
||||
{name: "Topics with no t-prefixed children — error",
|
||||
body: `{"Items":[{"ContainerType":"Topics","Title":"Episodes","Children":[{"GuideId":"p1"}]}]}`,
|
||||
wantError: true},
|
||||
{name: "malformed JSON — error", body: `{not json`, wantError: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseTuneInProgramContents([]byte(tc.body), "p290778")
|
||||
|
||||
if tc.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got id=%q", got)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tc.wantID {
|
||||
t.Errorf("got episode id %q, want %q", got, tc.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user