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:
Tobias Gesellchen
2026-05-17 18:29:48 +02:00
co-authored by Claude Opus 4.7
parent 4507d82b4c
commit cc7675a07c
5 changed files with 866 additions and 11 deletions
+152
View File
@@ -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
}
+157
View File
@@ -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)
}
}
+37
View File
@@ -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
View File
@@ -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"
+262
View File
@@ -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)
}
})
}
}