mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
38c771ad75
commit
11a6515f4d
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user