From cc7675a07ca30e30eea870483ae259aae03e3684 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 17 May 2026 18:25:52 +0200 Subject: [PATCH] feat(tunein): play stations/episodes/programs via `cli source tunein` (#226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`) are containers, not streams. The legacy OPML `Tune.ashx?id=p` 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` 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) --- cmd/soundtouch-cli/cmd_tunein.go | 152 +++++++++++++++ cmd/soundtouch-cli/cmd_tunein_test.go | 157 +++++++++++++++ cmd/soundtouch-cli/main.go | 37 ++++ pkg/service/bmx/bmx.go | 269 ++++++++++++++++++++++++-- pkg/service/bmx/bmx_test.go | 262 +++++++++++++++++++++++++ 5 files changed, 866 insertions(+), 11 deletions(-) create mode 100644 cmd/soundtouch-cli/cmd_tunein.go create mode 100644 cmd/soundtouch-cli/cmd_tunein_test.go diff --git a/cmd/soundtouch-cli/cmd_tunein.go b/cmd/soundtouch-cli/cmd_tunein.go new file mode 100644 index 0000000..7c7bcff --- /dev/null +++ b/cmd/soundtouch-cli/cmd_tunein.go @@ -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 +} diff --git a/cmd/soundtouch-cli/cmd_tunein_test.go b/cmd/soundtouch-cli/cmd_tunein_test.go new file mode 100644 index 0000000..6552c13 --- /dev/null +++ b/cmd/soundtouch-cli/cmd_tunein_test.go @@ -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) + } +} diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 1b2bc95..b96f2a3 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -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", diff --git a/pkg/service/bmx/bmx.go b/pkg/service/bmx/bmx.go index fc04589..745fa49 100644 --- a/pkg/service/bmx/bmx.go +++ b/pkg/service/bmx/bmx.go @@ -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`) episodes. The legacy OPML endpoints can't — + // `Tune.ashx?id=p` returns `#STATUS: 400`, `Browse.ashx?id=p` + // 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 → t 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: ` 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` 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` — topic/episode; played directly. +// - `e` — live episode; played directly. +// - `p` — 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` +// 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" diff --git a/pkg/service/bmx/bmx_test.go b/pkg/service/bmx/bmx_test.go index 11a45e4..b8e4716 100644 --- a/pkg/service/bmx/bmx_test.go +++ b/pkg/service/bmx/bmx_test.go @@ -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 → t 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 → t 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) + } + }) + } +}