feat(tunein): add section-grouped results and load-more pagination

TuneIn's profiles API caps initial results at ~10 per container (Stations,
Shows, etc.) and exposes a Pivots.More.Url cursor for the remainder. This
change wires that cursor through the stack so users can load additional
results without leaving the search view.

- tuneInSearchSection now extracts Pivots.More.Url as bmx_next when
  itemToken is present; absent for containers already at their limit
- TuneInSearchNext fetches the cursor URL, which returns a flat Items[]
  (not nested containers), and maps Station/Program/Topic items using
  the existing play/profile builders
- New GET /v1/search/next and /api/tunein/search/next endpoints with
  matching handlers in both service paths
- TuneInBrowser: flat items state replaced with per-section sections
  state; each section shows a header label and a Load more button when
  a cursor is available; browse/navigate mode is unaffected

Relates to #336.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-23 14:25:01 +02:00
co-authored by Claude Sonnet 4.6
parent 38c771ad75
commit 11a6515f4d
10 changed files with 193 additions and 34 deletions
+1
View File
@@ -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)
})
+1
View File
@@ -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
+1
View File
@@ -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.
+70
View File
@@ -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 == "" {
@@ -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")
+21
View File
@@ -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
+1
View File
@@ -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)
@@ -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 {
@@ -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}`, {
@@ -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`<div class="loading-bar"></div>` : null}
<ul class="tunein-list">
${items.map((item, i) => {
const isNav = !!navPath(item);
const play = playbackInfo(item);
return html`
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
${item.imageUrl ? html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />` : null}
<div class="tunein-item-info">
<span class="tunein-item-name">${item.name}</span>
${item.subtitle ? html`<span class="tunein-item-desc">${item.subtitle}</span>` : null}
</div>
${play ? html`
<button
class="tunein-play-btn"
title="Play"
onClick=${(e) => {
e.stopPropagation();
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
}}
></button>
` : null}
${isNav ? html`<span class="tunein-item-arrow"></span>` : null}
</li>
`;
})}
</ul>
${sections.map(section => html`
<div>
${section.name ? html`<h4 class="tunein-section-name">${section.name}</h4>` : null}
<ul class="tunein-list">
${section.items.map((item, i) => {
const isNav = !!navPath(item);
const play = playbackInfo(item);
return html`
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
${item.imageUrl ? html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />` : null}
<div class="tunein-item-info">
<span class="tunein-item-name">${item.name}</span>
${item.subtitle ? html`<span class="tunein-item-desc">${item.subtitle}</span>` : null}
</div>
${play ? html`
<button
class="tunein-play-btn"
title="Play"
onClick=${(e) => {
e.stopPropagation();
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
}}
></button>
` : null}
${isNav ? html`<span class="tunein-item-arrow"></span>` : null}
</li>
`;
})}
</ul>
${section.nextCursor ? html`
<button class="btn-secondary tunein-load-more" onClick=${() => loadMore(section)}>
Load more
</button>
` : null}
</div>
`)}
${pendingPlay ? html`
<div class="overlay" onClick=${() => setPendingPlay(null)}>