diff --git a/cmd/soundtouch-web/handlers/handlers.go b/cmd/soundtouch-web/handlers/handlers.go index b976d5a..4534857 100644 --- a/cmd/soundtouch-web/handlers/handlers.go +++ b/cmd/soundtouch-web/handlers/handlers.go @@ -12,6 +12,7 @@ import ( "github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes" "github.com/gesellix/bose-soundtouch/pkg/models" + bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx" "github.com/gorilla/websocket" ) @@ -557,3 +558,150 @@ func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) { client.Close() } } + +// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package. +func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + if query == "" { + app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest) + return + } + + resp, err := bmxpkg.TuneInSearch(query) + 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 +// - /{encodedURI} → browse the given TuneIn URI +// - /sub/{n}/{encodedURI} → single subsection +// - /profiles/{type}/{id}/{encodedURI} → artist/program profile +func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) { + const navPrefix = "/api/tunein/navigate" + + path := r.URL.Path + wildcard := "" + + if len(path) > len(navPrefix) { + wildcard = strings.TrimPrefix(path[len(navPrefix):], "/") + } + + var ( + resp interface{} + err error + ) + + if wildcard == "" { + resp, err = bmxpkg.TuneInNavigate("", nil) + } else { + firstSlash := strings.Index(wildcard, "/") + if firstSlash == -1 { + resp, err = bmxpkg.TuneInNavigate(wildcard, nil) + } else { + pfx := wildcard[:firstSlash] + rest := wildcard[firstSlash+1:] + + switch pfx { + case "sub": + secondSlash := strings.Index(rest, "/") + if secondSlash == -1 { + resp, err = bmxpkg.TuneInNavigate(rest, nil) + } else { + n, parseErr := strconv.Atoi(rest[:secondSlash]) + if parseErr != nil { + resp, err = bmxpkg.TuneInNavigate(wildcard, nil) + } else { + resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n) + } + } + case "profiles": + parts := strings.SplitN(rest, "/", 3) + if len(parts) < 3 { + resp, err = bmxpkg.TuneInNavigate(wildcard, nil) + } else { + resp, err = bmxpkg.TuneInNavigateProfile(parts[2]) + } + default: + resp, err = bmxpkg.TuneInNavigate(wildcard, nil) + } + } + } + + 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) + } +} + +// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select. +func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimPrefix(r.URL.Path, "/api/tunein/play/") + if deviceID == "" { + app.sendError(w, "Device ID required", http.StatusBadRequest) + return + } + + device, exists := app.Devices[deviceID] + if !exists { + app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound) + return + } + + var req struct { + Location string `json:"location"` + Name string `json:"name"` + Type string `json:"type"` + ContainerArt string `json:"containerArt"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + app.sendError(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Location == "" { + app.sendError(w, "location is required", http.StatusBadRequest) + return + } + + itemType := req.Type + if itemType == "" { + itemType = "stationurl" + } + + contentItem := &models.ContentItem{ + Source: "TUNEIN", + Type: itemType, + Location: req.Location, + ItemName: req.Name, + IsPresetable: true, + ContainerArt: req.ContainerArt, + } + + if err := device.Client.SelectContentItem(contentItem); 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: map[string]string{"message": "Playing " + req.Name}}); encErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} diff --git a/cmd/soundtouch-web/main.go b/cmd/soundtouch-web/main.go index 8036520..6612960 100644 --- a/cmd/soundtouch-web/main.go +++ b/cmd/soundtouch-web/main.go @@ -100,6 +100,12 @@ func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscov // Device control endpoints http.HandleFunc("/api/control/", app.HandleAPIControl) + // TuneIn browse, search, and playback + http.HandleFunc("/api/tunein/search", app.HandleTuneInSearch) + http.HandleFunc("/api/tunein/navigate", app.HandleTuneInNavigate) + http.HandleFunc("/api/tunein/navigate/", app.HandleTuneInNavigate) + http.HandleFunc("/api/tunein/play/", app.HandlePlayTuneIn) + // Enhanced device control endpoints with specific patterns http.HandleFunc("/api/device-key/", app.HandleDeviceKey) http.HandleFunc("/api/device-volume/", app.HandleDirectVolumeControl) diff --git a/cmd/soundtouch-web/static/css/app.css b/cmd/soundtouch-web/static/css/app.css index d3a6569..d3c11f1 100644 --- a/cmd/soundtouch-web/static/css/app.css +++ b/cmd/soundtouch-web/static/css/app.css @@ -810,6 +810,326 @@ body { } } +/* TuneIn brand icons */ +.tunein-nav-icon { + width: 28px; + height: auto; + vertical-align: middle; + display: block; +} + +.tunein-heading-icon { + width: 40px; + height: auto; + vertical-align: middle; +} + +[data-theme="dark"] .tunein-heading-icon { + filter: invert(1); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .tunein-heading-icon { + filter: invert(1); + } +} + +/* TuneIn Browse */ +.tunein-search-bar .form-control { + background-color: var(--card-bg); + color: var(--text-primary); + border-color: var(--border-color); +} + +.tunein-search-bar .form-control:focus { + background-color: var(--card-bg); + color: var(--text-primary); + border-color: var(--bose-accent); + box-shadow: 0 0 0 0.2rem rgba(0, 102, 204, 0.25); +} + +.tunein-section { + margin-bottom: 2rem; +} + +.tunein-section-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.75rem; + padding-bottom: 0.4rem; + border-bottom: 2px solid var(--bose-accent); +} + +/* Ribbon — horizontal scroll row */ +.tunein-ribbon { + display: flex; + overflow-x: auto; + gap: 12px; + padding-bottom: 8px; + scrollbar-width: thin; + scrollbar-color: var(--border-color) transparent; +} + +.tunein-ribbon::-webkit-scrollbar { + height: 4px; +} + +.tunein-ribbon::-webkit-scrollbar-thumb { + background-color: var(--border-color); + border-radius: 4px; +} + +.tunein-ribbon-item { + flex: 0 0 120px; + text-align: center; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 8px; +} + +/* Grid layout */ +.tunein-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 12px; +} + +.tunein-grid-item { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 8px; + text-align: center; +} + +/* List layout */ +.tunein-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.tunein-list-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +/* Hero layout */ +.tunein-hero { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 1rem; +} + +.tunein-hero-item { + position: relative; + border-radius: 10px; + overflow: hidden; + background: var(--card-bg); + border: 1px solid var(--border-color); + aspect-ratio: 16/9; +} + +.tunein-hero-item .tunein-item-image { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 0; +} + +.tunein-hero-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 10px 12px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + color: #fff; +} + +.tunein-hero-name { + font-weight: 600; + font-size: 0.9rem; + line-height: 1.3; +} + +.tunein-hero-subtitle { + font-size: 0.78rem; + opacity: 0.85; +} + +.tunein-hero-play { + position: absolute; + top: 8px; + right: 8px; +} + +/* Shared item styles */ +.tunein-item-image { + width: 60px; + height: 60px; + object-fit: cover; + border-radius: 6px; + flex-shrink: 0; +} + +.tunein-ribbon-item .tunein-item-image, +.tunein-grid-item .tunein-item-image { + width: 100%; + height: 88px; + margin-bottom: 6px; + border-radius: 6px; +} + +.tunein-item-placeholder { + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + color: var(--text-muted); + font-size: 1.4rem; +} + +.tunein-item-info { + flex: 1; + min-width: 0; +} + +.tunein-item-name { + font-size: 0.9rem; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tunein-item-label { + font-size: 0.8rem; + font-weight: 500; + color: var(--text-primary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.3; + margin-top: 2px; +} + +.tunein-item-subtitle { + font-size: 0.78rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; +} + +.tunein-item-chevron { + color: var(--text-muted); + flex-shrink: 0; + font-size: 0.85rem; +} + +/* Clickable items */ +.tunein-nav-item { + cursor: pointer; +} + +.tunein-nav-item:hover, +.tunein-nav-item:focus { + border-color: var(--bose-accent); + outline: none; +} + +.tunein-list-item.tunein-nav-item:hover { + transform: translateX(2px); +} + +.tunein-ribbon-item.tunein-nav-item:hover, +.tunein-grid-item.tunein-nav-item:hover, +.tunein-hero-item.tunein-nav-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 10px var(--shadow-color); +} + +/* Play button on TuneIn items */ +.tunein-play-btn { + background: var(--bose-accent); + color: #fff; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + min-width: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + padding: 0; +} + +.tunein-play-btn:hover { + background: #0056b3; + transform: scale(1.1); +} + +.tunein-play-btn:active { + transform: scale(0.95); +} + +.tunein-ribbon-item .tunein-play-btn, +.tunein-grid-item .tunein-play-btn { + width: 28px; + height: 28px; + min-width: 28px; + font-size: 0.8rem; + margin: 4px auto 0; +} + +/* Modal theming */ +.modal-content { + background-color: var(--card-bg); + color: var(--text-primary); + border-color: var(--border-color); +} + +.modal-header { + border-bottom-color: var(--border-color); +} + +/* Play icon badge on audio-only items */ +.tunein-item-play-badge { + font-size: 0.7rem; + color: var(--bose-accent); + margin-left: 4px; +} + +@media (max-width: 576px) { + .tunein-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 8px; + } + + .tunein-hero { + grid-template-columns: 1fr; + } + + .tunein-ribbon-item { + flex: 0 0 100px; + } +} + /* Reduced Motion Support */ @media (prefers-reduced-motion: reduce) { *, diff --git a/cmd/soundtouch-web/static/img/tunein-dark.svg b/cmd/soundtouch-web/static/img/tunein-dark.svg new file mode 100644 index 0000000..9bbcba8 --- /dev/null +++ b/cmd/soundtouch-web/static/img/tunein-dark.svg @@ -0,0 +1,19 @@ + + + +Artboard Copy 9 +Created with Sketch. + + + + + + diff --git a/cmd/soundtouch-web/static/img/tunein-mono.svg b/cmd/soundtouch-web/static/img/tunein-mono.svg new file mode 100644 index 0000000..bbf5b5f --- /dev/null +++ b/cmd/soundtouch-web/static/img/tunein-mono.svg @@ -0,0 +1,22 @@ + + + + +Artboard Copy 9 +Created with Sketch. + + + + + + diff --git a/cmd/soundtouch-web/static/index.html b/cmd/soundtouch-web/static/index.html index afeb2ee..35f3a94 100644 --- a/cmd/soundtouch-web/static/index.html +++ b/cmd/soundtouch-web/static/index.html @@ -30,6 +30,18 @@ > + + TuneIn + + +
+
+

TuneInTuneIn Browse

+
+ + + + + +
+ +
+
+
@@ -122,6 +174,23 @@
+ + + diff --git a/cmd/soundtouch-web/static/js/app.js b/cmd/soundtouch-web/static/js/app.js index bdc64d3..48895ec 100644 --- a/cmd/soundtouch-web/static/js/app.js +++ b/cmd/soundtouch-web/static/js/app.js @@ -8,6 +8,8 @@ let reconnectAttempts = 0; let maxReconnectAttempts = 5; let devices = {}; let currentDeviceId = null; +let tuneInNavStack = []; +let tuneInPendingPlay = null; // Page navigation function showPage(pageId) { @@ -19,9 +21,216 @@ function showPage(pageId) { if (pageId === "devices") { currentDeviceId = null; loadDevices(); + } else if (pageId === "tunein" && tuneInNavStack.length === 0) { + tuneInBrowse(); } } +// ── TuneIn Browse ────────────────────────────────────────────────────────────── + +function tuneInBrowse() { + tuneInNavStack = [{ fetchUrl: "/api/tunein/navigate", label: "TuneIn" }]; + tuneInRenderBreadcrumb(); + tuneInFetchAndRender("/api/tunein/navigate"); +} + +function tuneInSearch(query) { + if (!query || !query.trim()) return; + const q = query.trim(); + document.getElementById("tunein-search-input").value = q; + const url = "/api/tunein/search?q=" + encodeURIComponent(q); + tuneInNavStack = [ + { fetchUrl: "/api/tunein/navigate", label: "TuneIn" }, + { fetchUrl: url, label: "Search: " + q }, + ]; + tuneInRenderBreadcrumb(); + tuneInFetchAndRender(url); +} + +function tuneInNavigate(navPath, label) { + const url = "/api/tunein/navigate/" + navPath; + tuneInNavStack.push({ fetchUrl: url, label: label || "Browse" }); + tuneInRenderBreadcrumb(); + tuneInFetchAndRender(url); +} + +function tuneInNavTo(index) { + tuneInNavStack = tuneInNavStack.slice(0, index + 1); + tuneInRenderBreadcrumb(); + tuneInFetchAndRender(tuneInNavStack[tuneInNavStack.length - 1].fetchUrl); +} + +function tuneInRenderBreadcrumb() { + const nav = document.getElementById("tunein-breadcrumb"); + if (tuneInNavStack.length <= 1) { + nav.style.display = "none"; + return; + } + nav.style.display = ""; + const items = tuneInNavStack + .map((entry, i) => { + if (i === tuneInNavStack.length - 1) { + return ``; + } + return `
`; + }) + .join(""); + nav.innerHTML = ``; +} + +function tuneInFetchAndRender(url) { + const el = document.getElementById("tunein-results"); + el.innerHTML = '
'; + fetch(url) + .then((r) => r.json()) + .then((data) => { + if (data.success) { + renderTuneInResponse(data.data); + } else { + el.innerHTML = `
${escapeHtml(data.error || "Failed to load TuneIn content")}
`; + } + }) + .catch(() => { + el.innerHTML = + '
Failed to load TuneIn content. Check your connection.
'; + }); +} + +function renderTuneInResponse(data) { + const el = document.getElementById("tunein-results"); + if (!data || !data.bmx_sections || data.bmx_sections.length === 0) { + el.innerHTML = + '

No results found

'; + return; + } + el.innerHTML = data.bmx_sections.map(renderTuneInSection).join(""); +} + +function renderTuneInSection(section) { + const layout = section.layout || "list"; + const items = section.items || []; + if (items.length === 0) return ""; + + const titleHtml = section.name + ? `
${escapeHtml(section.name)}
` + : ""; + + let itemsHtml; + if (layout === "ribbon") { + itemsHtml = `
${items.map((item) => renderTuneInItem(item, "ribbon")).join("")}
`; + } else if (layout === "hero") { + itemsHtml = `
${items.map((item) => renderTuneInItem(item, "hero")).join("")}
`; + } else if (layout === "responsiveGrid") { + itemsHtml = `
${items.map((item) => renderTuneInItem(item, "grid")).join("")}
`; + } else { + itemsHtml = `
${items.map((item) => renderTuneInItem(item, "list")).join("")}
`; + } + + return `
${titleHtml}${itemsHtml}
`; +} + +function tuneInNavPath(item) { + const href = item._links?.bmx_navigate?.href; + return href ? href.replace(/^\/v1\/navigate\/?/, "") : null; +} + +function renderTuneInItem(item, layout) { + const navPath = tuneInNavPath(item); + const isNavigable = !!navPath; + const playHref = item._links?.bmx_playback?.href; + const playType = item._links?.bmx_playback?.type || "stationurl"; + const isPlayable = !!playHref; + const name = item.name || ""; + const subtitle = item.subtitle || ""; + const imageUrl = item.imageUrl || ""; + + const navAttrs = isNavigable + ? `data-nav-path="${escapeHtml(navPath)}" data-nav-label="${escapeHtml(name)}" role="button" tabindex="0"` + : ""; + const navClass = isNavigable ? " tunein-nav-item" : ""; + + const playBtn = isPlayable + ? `` + : ""; + + const imgHtml = imageUrl + ? `` + : `
`; + + if (layout === "ribbon") { + return `
${imgHtml}
${escapeHtml(name)}
${playBtn}
`; + } + + if (layout === "hero") { + return `
${imgHtml}
${escapeHtml(name)}
${subtitle ? `
${escapeHtml(subtitle)}
` : ""}
${playBtn ? `
${playBtn}
` : ""}
`; + } + + if (layout === "grid") { + return `
${imgHtml}
${escapeHtml(name)}
${subtitle ? `
${escapeHtml(subtitle)}
` : ""}${playBtn ? `
${playBtn}
` : ""}
`; + } + + // list / shortList / default + return `
${imgHtml}
${escapeHtml(name)}
${subtitle ? `
${escapeHtml(subtitle)}
` : ""}
${isNavigable ? '' : ""}${playBtn}
`; +} + +function tuneInPlayClick(location, name, type, art) { + const deviceIds = Object.keys(devices); + if (deviceIds.length === 0) { + showToast("No Devices", "No SoundTouch devices found. Try discovering devices first.", "warning"); + return; + } + if (deviceIds.length === 1) { + tuneInPlay(deviceIds[0], location, name, type, art); + } else { + tuneInShowDevicePicker(location, name, type, art); + } +} + +function tuneInPlay(deviceId, location, name, type, art) { + fetch(`/api/tunein/play/${deviceId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ location, name, type, containerArt: art }), + }) + .then((r) => r.json()) + .then((data) => { + if (data.success) { + showToast("Now Playing", data.data?.message || name, "success"); + } else { + showToast("Playback Failed", data.error || "Could not play station", "error"); + } + }) + .catch(() => showToast("Playback Failed", "Could not reach device", "error")); +} + +function tuneInShowDevicePicker(location, name, type, art) { + tuneInPendingPlay = { location, name, type, art }; + const list = document.getElementById("devicePickerList"); + list.innerHTML = Object.entries(devices) + .map( + ([id, dev]) => + ``, + ) + .join(""); + new bootstrap.Modal(document.getElementById("devicePickerModal")).show(); +} + +function tuneInPlayOnDevice(deviceId) { + if (!tuneInPendingPlay) return; + const { location, name, type, art } = tuneInPendingPlay; + tuneInPendingPlay = null; + bootstrap.Modal.getInstance(document.getElementById("devicePickerModal")).hide(); + tuneInPlay(deviceId, location, name, type, art); +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + // WebSocket connection management function connectWebSocket() { const protocol = location.protocol === "https:" ? "wss:" : "ws:"; @@ -843,4 +1052,47 @@ document.addEventListener("DOMContentLoaded", function () { initializeTheme(); connectWebSocket(); loadDevices(); + + // TuneIn: keyboard search + document + .getElementById("tunein-search-input") + .addEventListener("keydown", function (e) { + if (e.key === "Enter") tuneInSearch(this.value); + }); + + // TuneIn: event delegation — play buttons take priority over navigation + document + .getElementById("tunein-results") + .addEventListener("click", function (e) { + const playBtn = e.target.closest(".tunein-play-btn"); + if (playBtn) { + e.preventDefault(); + e.stopPropagation(); + tuneInPlayClick( + playBtn.dataset.playLocation, + playBtn.dataset.playName, + playBtn.dataset.playType || "stationurl", + playBtn.dataset.playArt || "", + ); + return; + } + const item = e.target.closest("[data-nav-path]"); + if (item) { + e.preventDefault(); + tuneInNavigate(item.dataset.navPath, item.dataset.navLabel || ""); + } + }); + + // TuneIn: keyboard activation for navigable items + document + .getElementById("tunein-results") + .addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + const item = e.target.closest("[data-nav-path]"); + if (item) { + e.preventDefault(); + tuneInNavigate(item.dataset.navPath, item.dataset.navLabel || ""); + } + } + }); }); diff --git a/pkg/models/models.go b/pkg/models/models.go index df1c91a..371c08a 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -12,8 +12,13 @@ import ( // Link represents a navigational link with URL and client usage preferences. type Link struct { - Href string `json:"href" xml:"href,attr"` - UseInternalClient string `json:"useInternalClient,omitempty" xml:"useInternalClient,attr,omitempty"` + Href string `json:"href" xml:"href,attr"` + UseInternalClient string `json:"useInternalClient,omitempty" xml:"useInternalClient,attr,omitempty"` + ContainerArt string `json:"containerArt,omitempty" xml:"-"` + Filters interface{} `json:"filters,omitempty" xml:"-"` + Name string `json:"name,omitempty" xml:"-"` + Templated *bool `json:"templated,omitempty" xml:"-"` + Type string `json:"type,omitempty" xml:"-"` } // Links contains various navigation links used by BMX services. @@ -28,6 +33,32 @@ type Links struct { BmxFavorite *Link `json:"bmx_favorite,omitempty" xml:"bmx_favorite,omitempty"` BmxNowPlaying *Link `json:"bmx_nowplaying,omitempty" xml:"bmx_nowplaying,omitempty"` BmxTrack *Link `json:"bmx_track,omitempty" xml:"bmx_track,omitempty"` + BmxSearch *Link `json:"bmx_search,omitempty" xml:"-"` + BmxPlayback *Link `json:"bmx_playback,omitempty" xml:"-"` + BmxPreset *Link `json:"bmx_preset,omitempty" xml:"-"` +} + +// BmxNavItem represents a single item in a TuneIn browse or search result. +type BmxNavItem struct { + Links *Links `json:"_links,omitempty"` + ImageUrl string `json:"imageUrl,omitempty"` + Name string `json:"name"` + Subtitle string `json:"subtitle"` +} + +// BmxNavSection represents a group of navigation items with a layout hint. +type BmxNavSection struct { + Links *Links `json:"_links,omitempty"` + Items []BmxNavItem `json:"items"` + Layout string `json:"layout,omitempty"` + Name string `json:"name"` +} + +// BmxNavResponse is the top-level response for TuneIn navigate and search endpoints. +type BmxNavResponse struct { + Links *Links `json:"_links,omitempty"` + BmxSections []BmxNavSection `json:"bmx_sections"` + Layout string `json:"layout"` } // IconSet represents a collection of icons with different sizes for media content. diff --git a/pkg/service/bmx/bmx.go b/pkg/service/bmx/bmx.go index d33950c..06a4758 100644 --- a/pkg/service/bmx/bmx.go +++ b/pkg/service/bmx/bmx.go @@ -12,16 +12,494 @@ import ( "net/url" "strconv" "strings" + "time" "github.com/gesellix/bose-soundtouch/pkg/models" ) // TuneIn endpoint templates used to resolve station and stream URLs. const ( - TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s" - TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg" + TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s" + TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg" + TuneInNavigateAshx = "http://opml.radiotime.com/?render=json" + TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query=" ) +var tuneInClient = &http.Client{Timeout: 10 * time.Second} + +func fetchJSON(fetchURL string) (map[string]interface{}, error) { + resp, err := tuneInClient.Get(fetchURL) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + return result, nil +} + +func decodeBase64URI(encoded string) (string, error) { + b, err := base64.URLEncoding.DecodeString(encoded) + if err != nil { + b, err = base64.StdEncoding.DecodeString(encoded) + } + + if err != nil { + return "", err + } + + return string(b), nil +} + +// TuneInNavigate returns a live browse response for the given encoded TuneIn URI. +// Pass subsection as nil for a full page, or a pointer to an int for a single subsection. +func TuneInNavigate(encodedURI string, subsection *int) (*models.BmxNavResponse, error) { + var ( + tuneInURI string + bmxSearchLink *models.Link + ) + + if encodedURI != "" { + decoded, err := decodeBase64URI(encodedURI) + if err != nil { + return nil, err + } + + tuneInURI = decoded + } else { + tuneInURI = TuneInNavigateAshx + templated := true + bmxSearchLink = &models.Link{ + Filters: []interface{}{}, + Href: "/v1/search?q={query}", + Templated: &templated, + } + } + + var ( + sections []models.BmxNavSection + err error + ) + + if strings.HasPrefix(tuneInURI, "http://opml.radiotime.com/") { + sections, err = tuneInSectionsAshx(tuneInURI, subsection) + } else { + sections, err = tuneInSectionsJSONAPI(tuneInURI, subsection) + } + + if err != nil { + return nil, err + } + + var subsectionPart, uriPart string + if subsection != nil { + subsectionPart = fmt.Sprintf("/sub/%d", *subsection) + } + + if encodedURI != "" { + uriPart = "/" + encodedURI + } + + return &models.BmxNavResponse{ + Links: &models.Links{ + Self: &models.Link{Href: fmt.Sprintf("/v1/navigate%s%s", subsectionPart, uriPart)}, + BmxSearch: bmxSearchLink, + }, + BmxSections: sections, + Layout: "classic", + }, nil +} + +func tuneInSectionsAshx(tuneInURI string, subsection *int) ([]models.BmxNavSection, error) { + data, err := fetchJSON(tuneInURI) + if err != nil { + return nil, err + } + + layout := "list" + + var ( + sections []models.BmxNavSection + topItems []models.BmxNavItem + ) + + body, _ := data["body"].([]interface{}) + + for idx, rawItem := range body { + item, ok := rawItem.(map[string]interface{}) + if !ok { + continue + } + + itemType, _ := item["type"].(string) + if itemType == "link" { + topItems = append(topItems, tuneInNavigateLink(item)) + continue + } + + if subsection != nil && *subsection != idx { + continue + } + + if len(body) == 1 || subsection != nil { + layout = "responsiveGrid" + } else { + layout = "ribbon" + } + + maxCount := 5 + if layout == "responsiveGrid" { + maxCount = 500 + } + + sectionTitle, _ := item["text"].(string) + + var sectionItems []models.BmxNavItem + + count := 0 + + children, _ := item["children"].([]interface{}) + for _, rawChild := range children { + child, ok := rawChild.(map[string]interface{}) + if !ok { + continue + } + + childType, _ := child["type"].(string) + switch childType { + case "audio": + sectionItems = append(sectionItems, tuneInNavigatePlayItem(child)) + case "link": + sectionItems = append(sectionItems, tuneInNavigateLink(child)) + } + + count++ + if count >= maxCount { + break + } + } + + encURI := base64.URLEncoding.EncodeToString([]byte(tuneInURI)) + sections = append(sections, models.BmxNavSection{ + Links: &models.Links{Self: &models.Link{Href: fmt.Sprintf("/v1/navigate/sub/%d/%s", idx, encURI)}}, + Items: sectionItems, + Layout: layout, + Name: sectionTitle, + }) + } + + head, _ := data["head"].(map[string]interface{}) + title, _ := head["title"].(string) + + var subsectionPart string + if subsection != nil { + subsectionPart = fmt.Sprintf("sub/%d/", *subsection) + } + + encURI := base64.URLEncoding.EncodeToString([]byte(tuneInURI)) + sections = append(sections, models.BmxNavSection{ + Links: &models.Links{Self: &models.Link{Href: fmt.Sprintf("/v1/navigate/%s%s", subsectionPart, encURI)}}, + Items: topItems, + Layout: layout, + Name: title, + }) + + return sections, nil +} + +func tuneInSectionsJSONAPI(tuneInURI string, subsection *int) ([]models.BmxNavSection, error) { + data, err := fetchJSON(tuneInURI) + if err != nil { + return nil, err + } + + var sections []models.BmxNavSection + + items, _ := data["Items"].([]interface{}) + for idx, rawItem := range items { + item, ok := rawItem.(map[string]interface{}) + if !ok { + continue + } + + if subsection != nil && *subsection != idx { + continue + } + + itemType, _ := item["Type"].(string) + containerType, _ := item["ContainerType"].(string) + + if itemType == "Container" && containerType != "NotPlayableStations" { + sections = append(sections, tuneInSearchSection(item, idx, "", "shortList")) + } + } + + return sections, nil +} + +func tuneInNavigatePlayItem(item map[string]interface{}) models.BmxNavItem { + guideID, _ := item["guide_id"].(string) + imageURL, _ := item["image"].(string) + text, _ := item["text"].(string) + subtext, _ := item["subtext"].(string) + + playbackHref := fmt.Sprintf("/v1/playback/station/%s", guideID) + + return models.BmxNavItem{ + Links: &models.Links{ + BmxPlayback: &models.Link{Href: playbackHref, Type: "stationurl"}, + BmxPreset: &models.Link{ContainerArt: imageURL, Href: guideID, Name: text, Type: "stationurl"}, + }, + ImageUrl: imageURL, + Name: text, + Subtitle: subtext, + } +} + +func tuneInNavigateLink(item map[string]interface{}) models.BmxNavItem { + rawURL, _ := item["URL"].(string) + imageURL, _ := item["image"].(string) + text, _ := item["text"].(string) + subtext, _ := item["subtext"].(string) + + encURL := base64.URLEncoding.EncodeToString([]byte(rawURL + "&render=json")) + + return models.BmxNavItem{ + Links: &models.Links{BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/%s", encURL)}}, + ImageUrl: imageURL, + Name: text, + Subtitle: subtext, + } +} + +// TuneInSearch returns live search results from TuneIn for the given query. +func TuneInSearch(query string) (*models.BmxNavResponse, error) { + tuneInURI := TuneInSearchAPI + url.QueryEscape(query) + + templated := true + bmxSearchLink := &models.Link{ + Filters: []interface{}{}, + Href: "/v1/search?q={query}", + Templated: &templated, + } + + data, err := fetchJSON(tuneInURI) + if err != nil { + return nil, err + } + + var sections []models.BmxNavSection + + items, _ := data["Items"].([]interface{}) + for idx, rawItem := range items { + item, ok := rawItem.(map[string]interface{}) + if !ok { + continue + } + + itemType, _ := item["Type"].(string) + containerType, _ := item["ContainerType"].(string) + + if itemType == "Container" && containerType != "NotPlayableStations" { + sections = append(sections, tuneInSearchSection(item, idx, query, "shortList")) + } + } + + return &models.BmxNavResponse{ + Links: &models.Links{ + Self: &models.Link{Href: fmt.Sprintf("/v1/search?q=%s", query)}, + BmxSearch: bmxSearchLink, + }, + BmxSections: sections, + Layout: "classic", + }, nil +} + +func tuneInSearchSection(item map[string]interface{}, idx int, query, layout string) models.BmxNavSection { + pivots, _ := item["Pivots"].(map[string]interface{}) + more, _ := pivots["More"].(map[string]interface{}) + pivotURL, _ := more["Url"].(string) + + var href string + if pivotURL != "" { + href = fmt.Sprintf("/v1/navigate/%s", base64.URLEncoding.EncodeToString([]byte(pivotURL))) + } else { + encodedQuery := base64.URLEncoding.EncodeToString([]byte(TuneInSearchAPI + query)) + href = fmt.Sprintf("/v1/navigate/sub/%d/%s", idx, encodedQuery) + } + + var sectionItems []models.BmxNavItem + + children, _ := item["Children"].([]interface{}) + for _, rawChild := range children { + child, ok := rawChild.(map[string]interface{}) + if !ok { + continue + } + + childType, _ := child["Type"].(string) + switch childType { + case "Station": + sectionItems = append(sectionItems, tuneInSearchPlayItem(child)) + case "Topic": + sectionItems = append(sectionItems, tuneInSearchTopic(child)) + case "Program": + sectionItems = append(sectionItems, tuneInSearchProfile(child, "Program")) + case "Artist": + sectionItems = append(sectionItems, tuneInSearchProfile(child, "Artist")) + case "Category": + actions, _ := child["Actions"].(map[string]interface{}) + browse, _ := actions["Browse"].(map[string]interface{}) + categoryHref, _ := browse["Url"].(string) + encHref := base64.URLEncoding.EncodeToString([]byte(categoryHref)) + image, _ := child["Image"].(string) + title, _ := child["Title"].(string) + subtitle, _ := child["Subtitle"].(string) + sectionItems = append(sectionItems, models.BmxNavItem{ + Links: &models.Links{BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/%s", encHref)}}, + ImageUrl: image, + Name: title, + Subtitle: subtitle, + }) + } + } + + title, _ := item["Title"].(string) + + return models.BmxNavSection{ + Links: &models.Links{Self: &models.Link{Href: href}}, + Items: sectionItems, + Layout: layout, + Name: title, + } +} + +func tuneInSearchPlayItem(item map[string]interface{}) models.BmxNavItem { + guideID, _ := item["GuideId"].(string) + image, _ := item["Image"].(string) + title, _ := item["Title"].(string) + subtitle, _ := item["Subtitle"].(string) + + href := fmt.Sprintf("/v1/playback/station/%s", guideID) + + return models.BmxNavItem{ + Links: &models.Links{ + BmxPlayback: &models.Link{Href: href, Type: "stationurl"}, + BmxPreset: &models.Link{ContainerArt: image, Href: href, Name: title, Type: "stationurl"}, + }, + ImageUrl: image, + Name: title, + Subtitle: subtitle, + } +} + +func tuneInSearchTopic(item map[string]interface{}) models.BmxNavItem { + guideID, _ := item["GuideId"].(string) + image, _ := item["Image"].(string) + title, _ := item["Title"].(string) + subtitle, _ := item["Subtitle"].(string) + + encodedName := base64.URLEncoding.EncodeToString([]byte(title)) + href := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", guideID, encodedName) + + return models.BmxNavItem{ + Links: &models.Links{ + BmxPlayback: &models.Link{Href: href, Type: "tracklisturl"}, + BmxPreset: &models.Link{ContainerArt: image, Href: href, Name: title, Type: "tracklisturl"}, + }, + ImageUrl: image, + Name: title, + Subtitle: subtitle, + } +} + +func tuneInSearchProfile(item map[string]interface{}, name string) models.BmxNavItem { + guideID, _ := item["GuideId"].(string) + image, _ := item["Image"].(string) + title, _ := item["Title"].(string) + subtitle, _ := item["Subtitle"].(string) + + actions, _ := item["Actions"].(map[string]interface{}) + profile, _ := actions["Profile"].(map[string]interface{}) + apiURL, _ := profile["Url"].(string) + apiURLEncoded := base64.URLEncoding.EncodeToString([]byte(apiURL)) + + 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"}, + }, + ImageUrl: image, + Name: title, + Subtitle: subtitle, + } +} + +// TuneInNavigateProfile returns a profile (artist/program) navigation response. +func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) { + tuneInURI, err := decodeBase64URI(encodedURI) + if err != nil { + return nil, err + } + + profileData, err := fetchJSON(tuneInURI) + if err != nil { + return nil, err + } + + profileItem, _ := profileData["Item"].(map[string]interface{}) + profileTitle, _ := profileItem["Title"].(string) + profileImage, _ := profileItem["Image"].(string) + profileSubtitle, _ := profileItem["Subtitle"].(string) + + sections := []models.BmxNavSection{ + { + Items: []models.BmxNavItem{{Name: profileTitle, ImageUrl: profileImage, Subtitle: profileSubtitle}}, + Layout: "hero", + Name: "", + }, + } + + pivots, _ := profileItem["Pivots"].(map[string]interface{}) + contents, _ := pivots["Contents"].(map[string]interface{}) + contentsURL, _ := contents["Url"].(string) + + if contentsURL != "" { + if contentsData, fetchErr := fetchJSON(contentsURL); fetchErr == nil { + contentsItems, _ := contentsData["Items"].([]interface{}) + for idx, rawItem := range contentsItems { + item, ok := rawItem.(map[string]interface{}) + if !ok { + continue + } + + itemType, _ := item["Type"].(string) + containerType, _ := item["ContainerType"].(string) + + if itemType == "Container" && containerType != "NotPlayableStations" { + sections = append(sections, tuneInSearchSection(item, idx, "", "list")) + } + } + } + } + + return &models.BmxNavResponse{ + Links: &models.Links{Self: &models.Link{Href: fmt.Sprintf("/v1/navigate/profiles/%s", encodedURI)}}, + BmxSections: sections, + Layout: "classic", + }, nil +} + // TuneInPlayback resolves a live radio station and returns a Bose-compatible // playback response with primary stream and variants. func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) { diff --git a/pkg/service/handlers/handlers_bmx.go b/pkg/service/handlers/handlers_bmx.go index 47f3886..da52836 100644 --- a/pkg/service/handlers/handlers_bmx.go +++ b/pkg/service/handlers/handlers_bmx.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/url" + "strconv" "strings" "github.com/gesellix/bose-soundtouch/pkg/service/bmx" @@ -247,24 +248,96 @@ func (s *Server) HandleTuneInReport(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("{}")) } -// HandleTuneInNavigate returns TuneIn navigation information. +// HandleTuneInNavigate returns live TuneIn navigation results. +// Path variants handled via chi wildcard: +// - (empty) → top-level browse +// - {encodedURI} → browse the given TuneIn URI +// - sub/{n}/{encodedURI} → single subsection of a browse page +// - profiles/{type}/{id}/{encodedURI} → artist/program profile page func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") == "" { s.writeBMXUnauthorized(w) return } + wildcard := chi.URLParam(r, "*") + + resp, err := parseTuneInNavigatePath(wildcard) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(tuneInNavigateJSON) + + if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } } -// HandleTuneInSearch returns TuneIn search results. +func parseTuneInNavigatePath(wildcard string) (interface{}, error) { + if wildcard == "" { + return bmx.TuneInNavigate("", nil) + } + + firstSlash := strings.Index(wildcard, "/") + if firstSlash == -1 { + return bmx.TuneInNavigate(wildcard, nil) + } + + prefix := wildcard[:firstSlash] + rest := wildcard[firstSlash+1:] + + switch prefix { + case "sub": + secondSlash := strings.Index(rest, "/") + if secondSlash == -1 { + return bmx.TuneInNavigate(rest, nil) + } + + n, err := strconv.Atoi(rest[:secondSlash]) + if err != nil { + return bmx.TuneInNavigate(wildcard, nil) + } + + return bmx.TuneInNavigate(rest[secondSlash+1:], &n) + + case "profiles": + // profiles/{type}/{id}/{encodedURI} + parts := strings.SplitN(rest, "/", 3) + if len(parts) < 3 { + return bmx.TuneInNavigate(wildcard, nil) + } + + return bmx.TuneInNavigateProfile(parts[2]) + + default: + return bmx.TuneInNavigate(wildcard, nil) + } +} + +// HandleTuneInSearch returns live TuneIn search results for the given query. func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") == "" { s.writeBMXUnauthorized(w) return } + query := r.URL.Query().Get("q") + if query == "" { + http.Error(w, "query parameter 'q' is required", http.StatusBadRequest) + return + } + + resp, err := bmx.TuneInSearch(query) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(tuneInSearchJSON) + + if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } } diff --git a/pkg/service/handlers/handlers_bmx_tunein_test.go b/pkg/service/handlers/handlers_bmx_tunein_test.go index 069008a..b5d9221 100644 --- a/pkg/service/handlers/handlers_bmx_tunein_test.go +++ b/pkg/service/handlers/handlers_bmx_tunein_test.go @@ -1,6 +1,7 @@ package handlers import ( + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -32,7 +33,9 @@ func TestHandleTuneInNavigate(t *testing.T) { }) t.Run("Sub navigate", func(t *testing.T) { - req := httptest.NewRequest("GET", "/bmx/tunein/v1/navigate/some-path", nil) + // Use the top-level OPML URL as a valid encoded navigate target + encodedURI := base64.URLEncoding.EncodeToString([]byte("http://opml.radiotime.com/?render=json")) + req := httptest.NewRequest("GET", "/bmx/tunein/v1/navigate/"+encodedURI, nil) req.Header.Set("Authorization", "Bearer mock-token") w := httptest.NewRecorder() diff --git a/pkg/service/handlers/handlers_media.go b/pkg/service/handlers/handlers_media.go index 1ec5120..629abf8 100644 --- a/pkg/service/handlers/handlers_media.go +++ b/pkg/service/handlers/handlers_media.go @@ -23,12 +23,6 @@ var bmxServicesJSON []byte //go:embed static/bmx_services_availability.json var bmxServicesAvailabilityJSON []byte -//go:embed static/tunein_navigate.json -var tuneInNavigateJSON []byte - -//go:embed static/tunein_search.json -var tuneInSearchJSON []byte - // Upstream source available at https://worldwide.bose.com/updates/soundtouch?serialnumber=_serial_ // which results in a redirect to https://downloads.bose.com/ced/soundtouch/mr4_22097fe2/index.xml // diff --git a/pkg/service/handlers/static/tunein_navigate.json b/pkg/service/handlers/static/tunein_navigate.json deleted file mode 100644 index 719bacc..0000000 --- a/pkg/service/handlers/static/tunein_navigate.json +++ /dev/null @@ -1,332 +0,0 @@ -{ - "_links": { - "bmx_search": { - "filters": [], - "href": "/v1/search?q={query}", - "templated": true - }, - "self": { - "href": "/v1/navigate" - } - }, - "bmx_sections": [ - { - "_links": { - "self": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL2xvY2FsP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmdmlld01vZGVsPUZhbHNlJml0ZW1Ub2tlbj1CZ2dJQUFJQUFnQUJBQUVBQVFFQUFRZ0FBQQ==" - } - }, - "items": [ - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s25260", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s25260/images/logog.jpg?t=638151901560000000", - "href": "/v1/playback/station/s25260", - "name": "1LIVE", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s25260/images/logog.jpg?t=638151901560000000", - "name": "1LIVE", - "subtitle": "Für den Sektor" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s42828", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s42828/images/logog.png?t=636575935889670000", - "href": "/v1/playback/station/s42828", - "name": "Deutschlandfunk", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s42828/images/logog.png?t=636575935889670000", - "name": "Deutschlandfunk", - "subtitle": "Soundcheck" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s213886", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s213886/images/logog.jpg?t=639098687370000000", - "href": "/v1/playback/station/s213886", - "name": "WDR 2 Rheinland", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s213886/images/logog.jpg?t=639098687370000000", - "name": "WDR 2 Rheinland", - "subtitle": "Wir sind der Westen" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s16252", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s16252/images/logog.png?t=636674275828970000", - "href": "/v1/playback/station/s16252", - "name": "Radio Köln", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s16252/images/logog.png?t=636674275828970000", - "name": "Radio Köln", - "subtitle": "News, Wetter, Verkehr und der beste Mix" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s99166", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s99166/images/logog.jpg?t=639098688990000000", - "href": "/v1/playback/station/s99166", - "name": "WDR 2 Ruhrgebiet", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s99166/images/logog.jpg?t=639098688990000000", - "name": "WDR 2 Ruhrgebiet", - "subtitle": "Wir sind der Westen" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s20301", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s20301/images/logog.jpg?t=639083982470000000", - "href": "/v1/playback/station/s20301", - "name": "WDR 5", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s20301/images/logog.jpg?t=639083982470000000", - "name": "WDR 5", - "subtitle": "WDR 5 - Mitreden. Mitfühlen. Miterleben." - } - ], - "layout": "ribbon", - "name": "Local Radio" - }, - { - "_links": { - "self": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL3RyZW5kaW5nP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmdmlld01vZGVsPUZhbHNlJml0ZW1Ub2tlbj1CZ2dJQUFZQUJnQUJBQUVBQVFFQUFRZ0FBQQ==" - } - }, - "items": [ - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s110052", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s110052/images/logog.jpg?t=639015950340000000", - "href": "/v1/playback/station/s110052", - "name": "CNBC", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s110052/images/logog.jpg?t=639015950340000000", - "name": "CNBC", - "subtitle": "Unlocked #105 - Southern Mansion & Tiny Home CNULK00105R1H" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s7016", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s7016/images/logog.png?t=637977437790000000", - "href": "/v1/playback/station/s7016", - "name": "ABC NewsRadio", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s7016/images/logog.png?t=637977437790000000", - "name": "ABC NewsRadio", - "subtitle": "Continuous national coverage of opinion-free, independent and fa" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s20431", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s20431/images/logog.jpg?t=638113795120000000", - "href": "/v1/playback/station/s20431", - "name": "FOX News Radio", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s20431/images/logog.jpg?t=638113795120000000", - "name": "FOX News Radio", - "subtitle": "Kennedy Saves the World" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s24939", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s24939/images/logog.png?t=639107339520000000", - "href": "/v1/playback/station/s24939", - "name": "BBC Radio 1", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s24939/images/logog.png?t=639107339520000000", - "name": "BBC Radio 1", - "subtitle": "The biggest new pop and all-day vibes" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s3022", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s3022/images/logog.jpg?t=637281897030000000", - "href": "/v1/playback/station/s3022", - "name": "CNA938", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s3022/images/logog.jpg?t=637281897030000000", - "name": "CNA938", - "subtitle": "Asia First Weekend with Justine Moss" - } - ], - "layout": "ribbon", - "name": "Trending" - }, - { - "_links": { - "self": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL3Nwb3J0cz9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJnZpZXdNb2RlbD1GYWxzZSZpdGVtVG9rZW49QmdnSUFBZ0FDQUFCQUFFQUFRRUFBUWdBQUE=" - } - }, - "items": [ - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s354710", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/z8181/images/logog.jpg?t=639107567180000000", - "href": "/v1/playback/station/s354710", - "name": "Download the free TuneIn app", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/z8181/images/logog.jpg?t=639107567180000000", - "name": "Download the free TuneIn app", - "subtitle": "Download the free TuneIn app" - } - ], - "layout": "ribbon", - "name": "Sports" - }, - { - "_links": { - "self": { - "href": "/v1/navigate/" - } - }, - "items": [ - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL2MxMDAwMzU1MjY_c2VyaWFsPWNjYmU5MzQzLWI2NDEtNDIzMS1hYWEwLTkyNzUwZjY4YzI2NyZ2ZXJzaW9uPTEuMyZ2aWV3TW9kZWw9RmFsc2UmaXRlbVRva2VuPUJnZ0lBQVFBQkFBQkFBRUFBUUVBQVFnQUFB" - } - }, - "imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/speaker.png", - "name": "Apple Music Radio Stations", - "subtitle": "" - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL2MxMDAwMDAwODg_c2VyaWFsPWNjYmU5MzQzLWI2NDEtNDIzMS1hYWEwLTkyNzUwZjY4YzI2NyZ2ZXJzaW9uPTEuMyZ2aWV3TW9kZWw9RmFsc2UmaXRlbVRva2VuPUJnZ0lBQVVBQlFBQkFBRUFBUUVBQVFnQUFB" - } - }, - "imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/podcasts.png", - "name": "Podcasts", - "subtitle": "" - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL211c2ljP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmdmlld01vZGVsPUZhbHNlJml0ZW1Ub2tlbj1CZ2dJQUFjQUJ3QUJBQUVBQVFFQUFRZ0FBQQ==" - } - }, - "imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/note.png", - "name": "Music", - "subtitle": "" - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL2M1NzkyMj9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJnZpZXdNb2RlbD1GYWxzZSZpdGVtVG9rZW49QmdnSUFBa0FDUUFCQUFFQUFRRUFBUWdBQUE=" - } - }, - "imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/news.png", - "name": "News & Talk", - "subtitle": "" - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL3RhbGs_c2VyaWFsPWNjYmU5MzQzLWI2NDEtNDIzMS1hYWEwLTkyNzUwZjY4YzI2NyZ2ZXJzaW9uPTEuMyZ2aWV3TW9kZWw9RmFsc2UmaXRlbVRva2VuPUJnZ0lBQW9BQ2dBQkFBRUFBUUVBQVFnQUFB" - } - }, - "imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/microphone.png", - "name": "Talk", - "subtitle": "" - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL3JlZ2lvbnM_c2VyaWFsPWNjYmU5MzQzLWI2NDEtNDIzMS1hYWEwLTkyNzUwZjY4YzI2NyZ2ZXJzaW9uPTEuMyZ2aWV3TW9kZWw9RmFsc2UmaXRlbVRva2VuPUJnZ0lBQXNBQ3dBQkFBRUFBUUVBQVFnQUFB" - } - }, - "imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/location.png", - "name": "By Location", - "subtitle": "" - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9jYXRlZ29yaWVzL2xhbmd1YWdlcz9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJnZpZXdNb2RlbD1GYWxzZSZpdGVtVG9rZW49QmdnSUFBd0FEQUFCQUFFQUFRRUFBUWdBQUE=" - } - }, - "imageUrl": "https://media.bose.io/bmx-icons/tunein/top-menu/bubble.png", - "name": "By Language", - "subtitle": "" - } - ], - "name": "" - } - ], - "layout": "classic" -} diff --git a/pkg/service/handlers/static/tunein_search.json b/pkg/service/handlers/static/tunein_search.json deleted file mode 100644 index e651e37..0000000 --- a/pkg/service/handlers/static/tunein_search.json +++ /dev/null @@ -1,437 +0,0 @@ -{ - "_links": { - "self": { - "href": "/v1/search?q=music" - } - }, - "bmx_sections": [ - { - "_links": { - "self": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcz9mdWxsdGV4dFNlYXJjaD10cnVlJmZpbHRlcj1wJTNBc2hvdyZxdWVyeT1tdXNpYyZzZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFFQUFRQUFBQUFBREF3QUFRUVRWZ0FBQUJOV0FBQUE=" - } - }, - "items": [ - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Program/p783819/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wNzgzODE5P3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQUVBQVFBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ==" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/p783819/images/logog.png?t=637208895200000000", - "href": "/v1/preset/program/p783819", - "name": "Must-Hear Music", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/p783819/images/logog.png?t=637208895200000000", - "name": "Must-Hear Music", - "subtitle": "Billboard staffers discuss new music from artists across a variety of genres.Hosted on Acast. See acast.com/privacy for more information." - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Program/p813639/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wODEzNjM5P3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQUlBQWdBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ==" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/p813639/images/logog.png?t=635834647084430000", - "href": "/v1/preset/program/p813639", - "name": "Music Awards 2016", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/p813639/images/logog.png?t=635834647084430000", - "name": "Music Awards 2016", - "subtitle": "United States" - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Program/p967555/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wOTY3NTU1P3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQU1BQXdBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ==" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/p967555/images/logog.png?t=637217441150000000", - "href": "/v1/preset/program/p967555", - "name": "The Great Albums", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/p967555/images/logog.png?t=637217441150000000", - "name": "The Great Albums", - "subtitle": "Two indie rock musicians, Bill Lambusta and Brian Erickson, dive into great rock and pop music through the lens of the medium they care for most - the album. Every episode features a track-by-track review, discussions about the sounds they love, and..." - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Program/p939903/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wOTM5OTAzP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQVFBQkFBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ==" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/p939903/images/logog.png?t=638291015710000000", - "href": "/v1/preset/program/p939903", - "name": "He Sang/She Sang", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/p939903/images/logog.png?t=638291015710000000", - "name": "He Sang/She Sang", - "subtitle": "He Sang/She Sang is a new podcast from WQXR for the opera-curious and opera superfans who want to know what all those big voices are really singing about. The podcast follows the radio broadcast season of the Metropolitan Opera with a weekly..." - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Program/p860133/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wODYwMTMzP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQVVBQlFBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ==" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/p860133/images/logog.png?t=638863003740000000", - "href": "/v1/preset/program/p860133", - "name": "Drink Champs", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/p860133/images/logog.png?t=638863003740000000", - "name": "Drink Champs", - "subtitle": "Legendary Queens rapper-turned show host N.O.R.E. teams up with Miami hip-hop pioneer DJ EFN for a night of boozy conversation and boisterous storytelling. The hosts and guests engage together in fun, light-hearted conversation - looking back at their..." - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Program/p4696142/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wNDY5NjE0Mj9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFZQUJnQUJBQUVBREF3QUFRUVRWZ0FBQUJOV0FBQUE=" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/p4696142/images/logog.png?t=639004873490000000", - "href": "/v1/preset/program/p4696142", - "name": "Les pepites musicales de RFI", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/p4696142/images/logog.png?t=639004873490000000", - "name": "Les pepites musicales de RFI", - "subtitle": "Toute l’année, nos reporters croisent des artistes du continent et d’ailleurs. Dans leurs maisons, dans les coulisses des concerts, les chambres d’hôtel ou dans la rue se nouent des rencontres uniques où l’on parle de soi, du son et du monde. RFI vous..." - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Program/p4696122/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wNDY5NjEyMj9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFjQUJ3QUJBQUVBREF3QUFRUVRWZ0FBQUJOV0FBQUE=" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/p4696122/images/logog.png?t=639004867290000000", - "href": "/v1/preset/program/p4696122", - "name": "Afro-Club et Afro-Club Deluxe", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/p4696122/images/logog.png?t=639004867290000000", - "name": "Afro-Club et Afro-Club Deluxe", - "subtitle": "Le son de la nouvelle génération sur RFI ! À partir du 30/3/2026, du lundi au vendredi, de 20h10 à 21h00 TU, DJ Face Maker (Hervé Mandina) vous donne accès au Top 20 des artistes d'Afrique, des Caraïbes et des diasporas afros qui font vibrer les..." - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Program/p4696123/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wNDY5NjEyMz9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFnQUNBQUJBQUVBREF3QUFRUVRWZ0FBQUJOV0FBQUE=" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/p4696123/images/logog.png?t=639004867620000000", - "href": "/v1/preset/program/p4696123", - "name": "Bonnes Pulsations du Monde", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/p4696123/images/logog.png?t=639004867620000000", - "name": "Bonnes Pulsations du Monde", - "subtitle": "BPM – Bonnes Pulsations du Monde, c’est une sélection de chansons qui font l’actualité sur les 5 continents. D’Abidjan à Caracas, de Paris à Shanghai, qu’est-ce qui fait vibrer la planète ? Une fois par mois, BPM vous emmène à la rencontre d’un..." - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Program/p1119668/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wMTExOTY2OD9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFrQUNRQUJBQUVBREF3QUFRUVRWZ0FBQUJOV0FBQUE=" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/p1119668/images/logog.png?t=636592099583900000", - "href": "/v1/preset/program/p1119668", - "name": "Y'all Access", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/p1119668/images/logog.png?t=636592099583900000", - "name": "Y'all Access", - "subtitle": "Kelly Sutton has your All Access pass to all the VIP events around Music City! Party hop, hit the red carpets and go behind the scenes thanks to your \"Y'all Access\" pass!" - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Program/p946296/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9wOTQ2Mjk2P3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQW9BQ2dBQkFBRUFEQXdBQVFRVFZnQUFBQk5XQUFBQQ==" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/p946296/images/logog.png?t=638360999970000000", - "href": "/v1/preset/program/p946296", - "name": "The Popcast With Knox and Jamie", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/p946296/images/logog.png?t=638360999970000000", - "name": "The Popcast With Knox and Jamie", - "subtitle": "A weekly pop culture podcast seeking to educate on things that entertain, but do not matter.Hosted on Acast. See acast.com/privacy for more information." - } - ], - "layout": "shortList", - "name": "Shows" - }, - { - "_links": { - "self": { - "href": "/v1/navigate/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcz9mdWxsdGV4dFNlYXJjaD10cnVlJmZpbHRlcj1zJnF1ZXJ5PW11c2ljJnNlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQUlBQWdBQUFBQUFDd3NBQVFRVFZRQUFBQk5WQUFBQQ==" - } - }, - "items": [ - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s309467", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s309467/images/logog.jpg?t=637348332440000000", - "href": "/v1/playback/station/s309467", - "name": "Kidsradio.com", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s309467/images/logog.jpg?t=637348332440000000", - "name": "Kidsradio.com", - "subtitle": "Greece" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s301791", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s301791/images/logog.png?t=636480577103430000", - "href": "/v1/playback/station/s301791", - "name": "90s90s Dance", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s301791/images/logog.png?t=636480577103430000", - "name": "90s90s Dance", - "subtitle": "90s90s Dance: Der Dancesound der 90er." - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s281990", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s281990/images/logog.png?t=638156899930000000", - "href": "/v1/playback/station/s281990", - "name": "90s90s DAB", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s281990/images/logog.png?t=638156899930000000", - "name": "90s90s DAB", - "subtitle": "90s90s ist das Radio für den coolen Sound der 90er. Deutschlandweit im Digitalradio DAB+" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s308474", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s308474/images/logog.png?t=637014668910000000", - "href": "/v1/playback/station/s308474", - "name": "90s90s In The Mix", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s308474/images/logog.png?t=637014668910000000", - "name": "90s90s In The Mix", - "subtitle": "90s90s In The Mix: Der Sound der 90er nonstop gemixt – das Real 90s-DJ-Radio" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s323852", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s323852/images/logog.png?t=638197456570000000", - "href": "/v1/playback/station/s323852", - "name": "90s90s DANCE RADIO", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s323852/images/logog.png?t=638197456570000000", - "name": "90s90s DANCE RADIO", - "subtitle": "Kein Musikstil hat die Musikszene Deutschlands und das Leben von jungen Menschen so geprägt wie der" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s306625", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s306625/images/logog.png?t=636673358641530000", - "href": "/v1/playback/station/s306625", - "name": "90s90s Techno", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s306625/images/logog.png?t=636673358641530000", - "name": "90s90s Techno", - "subtitle": "Die Geburtsstunde von Techno - der typische 90s-Dancesound in ei" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s174864", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-radiotime-logos.tunein.com/s174864g.png", - "href": "/v1/playback/station/s174864", - "name": "Highway 65 Radio", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-radiotime-logos.tunein.com/s174864g.png", - "name": "Highway 65 Radio", - "subtitle": "Connecting listeners to the Country Music scene and lifestyle" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s323853", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s323853/images/logog.png?t=638197456800000000", - "href": "/v1/playback/station/s323853", - "name": "80s80s DANCE", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s323853/images/logog.png?t=638197456800000000", - "name": "80s80s DANCE", - "subtitle": "80s80s DANCE liefert den perfekten Dance-Sound aus den 80ern in einem eigenen Radio." - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s306908", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s306908/images/logog.png?t=636758140220000000", - "href": "/v1/playback/station/s306908", - "name": "90s90s RnB", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s306908/images/logog.png?t=636758140220000000", - "name": "90s90s RnB", - "subtitle": "Hip-Hop-Soul, neuer Funk und ein Schwung sexuell aufgeladener Ja" - }, - { - "_links": { - "bmx_playback": { - "href": "/v1/playback/station/s306584", - "type": "stationurl" - }, - "bmx_preset": { - "containerArt": "http://cdn-profiles.tunein.com/s306584/images/logog.png?t=636643926846930000", - "href": "/v1/playback/station/s306584", - "name": "90s90s Grunge", - "type": "stationurl" - } - }, - "imageUrl": "http://cdn-profiles.tunein.com/s306584/images/logog.png?t=636643926846930000", - "name": "90s90s Grunge", - "subtitle": "Wütende Musik der 90er: Grunge. Was in Seattle in den USA begann" - } - ], - "layout": "shortList", - "name": "Stations" - }, - { - "_links": { - "self": { - "href": "/v1/navigate/sub/2/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcz9mdWxsdGV4dHNlYXJjaD10cnVlJnZlcnNpb249MS4zJnNlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmcXVlcnk9bXVzaWM=" - } - }, - "items": [ - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Artist/m1038098/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9tMTAzODA5OD9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFFQUFRQURBQU1BRGc0QUFRUVRDd0FBQUJNTEFBQUE=" - }, - "bmx_preset": { - "containerArt": "http://cdn-radiotime-logos.tunein.com/s0q.png", - "href": "/v1/preset/program/m1038098", - "name": "Music Music Music", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-radiotime-logos.tunein.com/s0q.png", - "name": "Music Music Music", - "subtitle": "Gospel, Caribbean Music" - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Artist/m1444080/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9tMTQ0NDA4MD9zZXJpYWw9Y2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3JnZlcnNpb249MS4zJml0ZW1Ub2tlbj1CZ1FFQUFJQUFnQURBQU1BRGc0QUFRUVRDd0FBQUJNTEFBQUE=" - }, - "bmx_preset": { - "containerArt": "http://cdn-radiotime-logos.tunein.com/s0q.png", - "href": "/v1/preset/program/m1444080", - "name": "No Music", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-radiotime-logos.tunein.com/s0q.png", - "name": "No Music", - "subtitle": "Variety" - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Artist/m236951/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9tMjM2OTUxP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQU1BQXdBREFBTUFEZzRBQVFRVEN3QUFBQk1MQUFBQQ==" - }, - "bmx_preset": { - "containerArt": "http://cdn-albums.tunein.com/gn/40QJ66TZ3Wq.jpg", - "href": "/v1/preset/program/m236951", - "name": "The Music", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-albums.tunein.com/gn/40QJ66TZ3Wq.jpg", - "name": "The Music", - "subtitle": "Gospel, Rock" - }, - { - "_links": { - "bmx_navigate": { - "href": "/v1/navigate/profiles/Artist/m404700/aHR0cHM6Ly9hcGkucmFkaW90aW1lLmNvbS9wcm9maWxlcy9tNDA0NzAwP3NlcmlhbD1jY2JlOTM0My1iNjQxLTQyMzEtYWFhMC05Mjc1MGY2OGMyNjcmdmVyc2lvbj0xLjMmaXRlbVRva2VuPUJnUUVBQVFBQkFBREFBTUFEZzRBQVFRVEN3QUFBQk1MQUFBQQ==" - }, - "bmx_preset": { - "containerArt": "http://cdn-albums.tunein.com/gn/JDJC8456C0q.jpg", - "href": "/v1/preset/program/m404700", - "name": "Music Go Music", - "type": "tracklisturl" - } - }, - "imageUrl": "http://cdn-albums.tunein.com/gn/JDJC8456C0q.jpg", - "name": "Music Go Music", - "subtitle": "" - } - ], - "layout": "shortList", - "name": "Suggestions (Artist)" - } - ], - "layout": "classic" -}