diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 263742e..f143d9d 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -1005,6 +1005,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * r.Get("/v1/navigate", server.HandleTuneInNavigate) r.Get("/v1/navigate/*", server.HandleTuneInNavigate) r.Get("/v1/search", server.HandleTuneInSearch) + r.Get("/v1/search/next", server.HandleTuneInSearchNext) r.Post("/v1/favorite/{stationID}", server.HandleTuneInFavorite) r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite) }) diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index b6a9cc0..e420e65 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -31,6 +31,7 @@ GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.( GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm +GET /bmx/tunein/v1/search/next handlers.(*Server).HandleTuneInSearchNext-fm GET /ced/* handlers.(*Server).HandleCedStatic GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm diff --git a/pkg/models/models.go b/pkg/models/models.go index a158885..4485061 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -36,6 +36,7 @@ type Links struct { BmxSearch *Link `json:"bmx_search,omitempty" xml:"-"` BmxPlayback *Link `json:"bmx_playback,omitempty" xml:"-"` BmxPreset *Link `json:"bmx_preset,omitempty" xml:"-"` + BmxNext *Link `json:"bmx_next,omitempty" xml:"-"` } // BmxNavItem represents a single item in a TuneIn browse or search result. diff --git a/pkg/service/bmx/tunein.go b/pkg/service/bmx/tunein.go index 4a587f2..67bb6c6 100644 --- a/pkg/service/bmx/tunein.go +++ b/pkg/service/bmx/tunein.go @@ -361,6 +361,24 @@ func tuneInSearchSection(item map[string]interface{}, idx int, query, layout str } } + // Pivots.More.Url is the "load more" cursor from the TuneIn profiles API. + // It is only present when there are more results beyond the first page. + if pivots, ok := item["Pivots"].(map[string]interface{}); ok { + if more, ok := pivots["More"].(map[string]interface{}); ok { + if containerURL, _ := more["Url"].(string); strings.Contains(containerURL, "itemToken") { + if u, err := url.Parse(containerURL); err == nil && allowedTuneInHosts[u.Hostname()] { + encoded := base64.RawURLEncoding.EncodeToString([]byte(containerURL)) + + if section.Links == nil { + section.Links = &models.Links{} + } + + section.Links.BmxNext = &models.Link{Href: "/v1/search/next?cursor=" + encoded} + } + } + } + } + for _, child := range children { cm, ok := child.(map[string]interface{}) if !ok { @@ -386,6 +404,58 @@ func tuneInSearchSection(item map[string]interface{}, idx int, query, layout str return section } +// TuneInSearchNext fetches the remaining results for a section using the opaque +// cursor produced by TuneInSearch. The cursor URL returns a flat Items[] list +// (not nested containers), so we parse items directly rather than via +// tuneInSearchSection. TuneIn typically returns all remaining results in one +// shot; Paging is empty and no further cursor is generated. +func TuneInSearchNext(encodedCursor string) (*models.BmxNavResponse, error) { + cursorBytes, err := base64.RawURLEncoding.DecodeString(encodedCursor) + if err != nil { + return nil, fmt.Errorf("invalid cursor: %w", err) + } + + cursorURL := string(cursorBytes) + + u, err := url.Parse(cursorURL) + if err != nil || !allowedTuneInHosts[u.Hostname()] { + return nil, fmt.Errorf("cursor URL not allowed") + } + + data, err := fetchJSON(cursorURL) + if err != nil { + return nil, err + } + + rawItems, ok := data["Items"].([]interface{}) + if !ok { + rawItems, _ = data["body"].([]interface{}) + } + + navItems := make([]models.BmxNavItem, 0, len(rawItems)) + for _, raw := range rawItems { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + + typeStr, _ := m["Type"].(string) + switch typeStr { + case "Station", "PlayItem", "Topic": + navItems = append(navItems, tuneInSearchPlayItem(m)) + case "Program", "Profile": + navItems = append(navItems, tuneInSearchProfile(m, "")) + } + } + + return &models.BmxNavResponse{ + Layout: "classic", + BmxSections: []models.BmxNavSection{ + {Items: navItems, Layout: "grid"}, + }, + }, nil +} + func tuneInSearchPlayItem(item map[string]interface{}) models.BmxNavItem { name, _ := item["Title"].(string) if name == "" { diff --git a/pkg/service/handlers/handlers_bmx_tunein.go b/pkg/service/handlers/handlers_bmx_tunein.go index 5d778f4..1abeb4e 100644 --- a/pkg/service/handlers/handlers_bmx_tunein.go +++ b/pkg/service/handlers/handlers_bmx_tunein.go @@ -290,6 +290,33 @@ func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) { } } +// HandleTuneInSearchNext returns the next page of TuneIn search results using +// an opaque cursor produced by HandleTuneInSearch. +func (s *Server) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q", + r.URL.Path, r.UserAgent()) + } + + cursor := r.URL.Query().Get("cursor") + if cursor == "" { + http.Error(w, "cursor parameter required", http.StatusBadRequest) + return + } + + resp, err := bmx.TuneInSearchNext(cursor) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + // HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}. func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) { stationID := chi.URLParam(r, "stationID") diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index 64b7916..31e4994 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -638,6 +638,27 @@ func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) { } } +// HandleTuneInSearchNext returns the next page of TuneIn search results using an opaque cursor. +func (app *WebApp) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request) { + cursor := r.URL.Query().Get("cursor") + if cursor == "" { + app.sendError(w, "cursor parameter required", http.StatusBadRequest) + return + } + + resp, err := bmxpkg.TuneInSearchNext(cursor) + if err != nil { + app.sendError(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + // HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package. // Supported path suffixes (relative to /api/tunein/navigate): // - (empty) → top-level browse diff --git a/pkg/service/soundtouchweb/mount.go b/pkg/service/soundtouchweb/mount.go index 7a0cb3a..2b33e4d 100644 --- a/pkg/service/soundtouchweb/mount.go +++ b/pkg/service/soundtouchweb/mount.go @@ -50,6 +50,7 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov // TuneIn browse, search, and playback r.Get("/api/tunein/search", app.HandleTuneInSearch) + r.Get("/api/tunein/search/next", app.HandleTuneInSearchNext) r.Get("/api/tunein/navigate", app.HandleTuneInNavigate) r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate) r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn) diff --git a/pkg/service/soundtouchweb/static/css/app.css b/pkg/service/soundtouchweb/static/css/app.css index 3884e2b..653eab6 100644 --- a/pkg/service/soundtouchweb/static/css/app.css +++ b/pkg/service/soundtouchweb/static/css/app.css @@ -581,6 +581,8 @@ img { display: block; max-width: 100%; } transition: background .15s, border-color .15s; } .tunein-play-btn:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); } +.tunein-section-name { font-size: .85rem; font-weight: 600; color: var(--text-dim); padding: 8px 0 2px; margin: 0; } +.tunein-load-more { margin: 4px 0 12px; } /* ── Device picker overlay ───────────────────────────────────────────────── */ .overlay { diff --git a/pkg/service/soundtouchweb/static/js/api.js b/pkg/service/soundtouchweb/static/js/api.js index 27e0046..b6e7ecc 100644 --- a/pkg/service/soundtouchweb/static/js/api.js +++ b/pkg/service/soundtouchweb/static/js/api.js @@ -30,6 +30,7 @@ export const api = { }), tuneInBrowse: (path) => req(path ? `/api/tunein/navigate/${path}` : '/api/tunein/navigate'), tuneInSearch: (q) => req(`/api/tunein/search?q=${encodeURIComponent(q)}`), + tuneInSearchNext: (cursor) => req(`/api/tunein/search/next?cursor=${encodeURIComponent(cursor)}`), control: (id, action, presetId) => req(`/api/control/${id}/${action}?id=${presetId}`), selectSource: (id, source, account) => req(`/api/control/${id}/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`), tuneInPlay: (deviceId, item) => req(`/api/tunein/play/${deviceId}`, { diff --git a/pkg/service/soundtouchweb/static/js/components/TuneInBrowser.js b/pkg/service/soundtouchweb/static/js/components/TuneInBrowser.js index bbba1c7..3b5b44a 100644 --- a/pkg/service/soundtouchweb/static/js/components/TuneInBrowser.js +++ b/pkg/service/soundtouchweb/static/js/components/TuneInBrowser.js @@ -5,9 +5,10 @@ import { api } from '../api.js'; const html = htm.bind(h); -// BmxNavResponse has shape { bmx_sections: [{ name, items: [{ name, imageUrl, subtitle, _links }] }] } +// BmxNavResponse has shape { bmx_sections: [{ name, items: [{ name, imageUrl, subtitle, _links }], _links }] } // _links.bmx_navigate.href = "/v1/navigate/{encodedPath}" — strip prefix for API call // _links.bmx_playback.href = station/track URL, type = "stationurl"|"tracklisturl" +// _links.bmx_next.href = "/v1/search/next?cursor={base64}" — load-more cursor function navPath(item) { const href = item._links?.bmx_navigate?.href; @@ -19,15 +20,23 @@ function playbackInfo(item) { return link ? { location: link.href, type: link.type || 'stationurl' } : null; } -function flattenSections(data) { +function sectionCursor(section) { + const href = section._links?.bmx_next?.href; + if (!href) return null; + return new URLSearchParams(href.split('?')[1] || '').get('cursor'); +} + +function toSections(data) { if (!data?.bmx_sections) return []; - return data.bmx_sections.flatMap(section => - (section.items || []).map(item => ({ ...item, _sectionName: section.name })) - ); + return data.bmx_sections.map(s => ({ + name: s.name, + items: s.items || [], + nextCursor: sectionCursor(s), + })); } export function TuneInBrowser({ devices }) { - const [items, setItems] = useState([]); + const [sections, setSections] = useState([]); const [navStack, setNavStack] = useState([{ label: 'TuneIn', path: null }]); const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); @@ -39,7 +48,7 @@ export function TuneInBrowser({ devices }) { setLoading(true); const resp = await api.tuneInBrowse(path); setLoading(false); - if (resp.success) setItems(flattenSections(resp.data)); + if (resp.success) setSections(toSections(resp.data)); } async function search(q) { @@ -49,10 +58,25 @@ export function TuneInBrowser({ devices }) { setLoading(false); if (resp.success) { setNavStack([{ label: 'TuneIn', path: null }, { label: `"${q}"`, path: null }]); - setItems(flattenSections(resp.data)); + setSections(toSections(resp.data)); } } + async function loadMore(section) { + setLoading(true); + const resp = await api.tuneInSearchNext(section.nextCursor); + setLoading(false); + if (!resp.success) return; + const next = toSections(resp.data); + const newItems = next.flatMap(s => s.items); + const newCursor = next[0]?.nextCursor || null; + setSections(prev => prev.map(s => + s.name === section.name + ? { ...s, items: [...s.items, ...newItems], nextCursor: newCursor } + : s + )); + } + function navigate(item) { const path = navPath(item); const play = playbackInfo(item); @@ -111,32 +135,42 @@ export function TuneInBrowser({ devices }) { ${loading ? html`
` : null} - + ${sections.map(section => html` +
+ ${section.name ? html`

${section.name}

` : null} + + ${section.nextCursor ? html` + + ` : null} +
+ `)} ${pendingPlay ? html`
setPendingPlay(null)}>