Add TuneIn search/browse/playback

We might peek into https://github.com/core-hacked/tunein-api for more advanced use cases
This commit is contained in:
Tobias Gesellchen
2026-04-19 21:59:55 +02:00
parent 56256de47b
commit 56e82d5a01
14 changed files with 1430 additions and 784 deletions
+148
View File
@@ -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)
}
}
+6
View File
@@ -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)
+320
View File
@@ -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) {
*,
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 80 56" style="enable-background:new 0 0 80 56;" xml:space="preserve">
<title>Artboard Copy 9</title>
<desc>Created with Sketch.</desc>
<g id="Page-1">
<g id="Artboard-Copy-9">
<path id="TI_Badge_Black-Copy-2" d="M63.9,27.7c0-0.3-0.2-0.5-0.5-0.5h-2.1c-0.1,0-0.2-0.1-0.2-0.2V16.8c0-0.1,0.1-0.2,0.2-0.2
h1.8c0.3,0,0.5-0.2,0.5-0.5v-2.3c0-0.3-0.2-0.5-0.5-0.5h-7.6c-0.3,0-0.5,0.2-0.5,0.5v2.3c0,0.3,0.2,0.5,0.5,0.5h1.8
c0.1,0,0.2,0.1,0.2,0.2v10.1c0,0.1-0.1,0.2-0.2,0.2h-2.1c-0.3,0-0.5,0.2-0.5,0.5V30c0,0.3,0.2,0.5,0.5,0.5h8.1
c0.3,0,0.5-0.2,0.5-0.5V27.7z M38.2,17H4c-0.2,0-0.3,0.1-0.3,0.3v33.8c0,0.2,0.1,0.3,0.3,0.3h33.8c0.2,0,0.3-0.1,0.3-0.3V17z
M80,2.8V41c0,1-0.8,1.8-1.8,1.8H41.8v10.5c0,1-0.8,1.8-1.8,1.8H1.8c-1,0-1.8-0.8-1.8-1.8V15.1c0-1,0.8-1.8,1.8-1.8h36.3V2.8
C38.2,1.8,39,1,40,1h38.2C79.2,1,80,1.8,80,2.8z M14.8,28.5v-2.5c0-0.3,0.2-0.5,0.5-0.5h10.1c0.3,0,0.5,0.2,0.5,0.5v2.5
c0,0.3-0.2,0.5-0.5,0.5h-3.1c-0.1,0-0.2,0.1-0.2,0.2v13c0,0.3-0.2,0.5-0.5,0.5h-2.5c-0.3,0-0.5-0.2-0.5-0.5v-13
c0-0.1-0.1-0.2-0.2-0.2h-3.1C15,29,14.8,28.8,14.8,28.5L14.8,28.5z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 80 56" style="enable-background:new 0 0 80 56;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<title>Artboard Copy 9</title>
<desc>Created with Sketch.</desc>
<g id="Page-1">
<g id="Artboard-Copy-9">
<path id="TI_Badge_Black-Copy-2" class="st0" d="M63.9,27.7c0-0.3-0.2-0.5-0.5-0.5h-2.1c-0.1,0-0.2-0.1-0.2-0.2V16.8
c0-0.1,0.1-0.2,0.2-0.2h1.8c0.3,0,0.5-0.2,0.5-0.5v-2.3c0-0.3-0.2-0.5-0.5-0.5h-7.6c-0.3,0-0.5,0.2-0.5,0.5v2.3
c0,0.3,0.2,0.5,0.5,0.5h1.8c0.1,0,0.2,0.1,0.2,0.2v10.1c0,0.1-0.1,0.2-0.2,0.2h-2.1c-0.3,0-0.5,0.2-0.5,0.5V30
c0,0.3,0.2,0.5,0.5,0.5h8.1c0.3,0,0.5-0.2,0.5-0.5L63.9,27.7L63.9,27.7z M38.2,17H4c-0.2,0-0.3,0.1-0.3,0.3v33.8
c0,0.2,0.1,0.3,0.3,0.3h33.8c0.2,0,0.3-0.1,0.3-0.3V17H38.2z M80,2.8V41c0,1-0.8,1.8-1.8,1.8H41.8v10.5c0,1-0.8,1.8-1.8,1.8H1.8
c-1,0-1.8-0.8-1.8-1.8V15.1c0-1,0.8-1.8,1.8-1.8h36.3V2.8C38.2,1.8,39,1,40,1h38.2C79.2,1,80,1.8,80,2.8z M14.8,28.5V26
c0-0.3,0.2-0.5,0.5-0.5h10.1c0.3,0,0.5,0.2,0.5,0.5v2.5c0,0.3-0.2,0.5-0.5,0.5h-3.1c-0.1,0-0.2,0.1-0.2,0.2v13
c0,0.3-0.2,0.5-0.5,0.5h-2.5c-0.3,0-0.5-0.2-0.5-0.5v-13c0-0.1-0.1-0.2-0.2-0.2h-3.1C15,29,14.8,28.8,14.8,28.5L14.8,28.5z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+69
View File
@@ -30,6 +30,18 @@
>
<i class="bi bi-house"></i>
</a>
<a
class="nav-link tunein-nav-link"
href="#"
onclick="showPage('tunein')"
title="TuneIn Browse"
>
<img
src="/static/img/tunein-mono.svg"
alt="TuneIn"
class="tunein-nav-icon"
/>
</a>
<a
class="nav-link"
href="#"
@@ -86,6 +98,46 @@
</div>
</div>
<!-- TuneIn Browse Page -->
<div id="tunein-page" class="page">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
</div>
<div class="tunein-search-bar mb-3">
<div class="input-group">
<input
type="text"
id="tunein-search-input"
class="form-control"
placeholder="Search stations, podcasts..."
/>
<button
class="btn btn-primary"
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
>
<i class="bi bi-search"></i>
Search
</button>
<button
class="btn btn-outline-secondary"
onclick="tuneInBrowse()"
title="Browse top level"
>
<i class="bi bi-house"></i>
</button>
</div>
</div>
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
<!-- filled by JavaScript -->
</nav>
<div id="tunein-results">
<!-- filled by JavaScript -->
</div>
</div>
<!-- Device Control Page -->
<div id="device-page" class="page">
<div class="back-button">
@@ -122,6 +174,23 @@
<!-- Toast container for notifications -->
<div class="toast-container"></div>
<!-- Device picker for TuneIn playback -->
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="devicePickerLabel">
<i class="bi bi-speaker me-2"></i>Play on device
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-2" id="devicePickerList">
<!-- device buttons filled by JavaScript -->
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+252
View File
@@ -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 `<li class="breadcrumb-item active" aria-current="page">${escapeHtml(entry.label)}</li>`;
}
return `<li class="breadcrumb-item"><a href="#" onclick="tuneInNavTo(${i}); return false;">${escapeHtml(entry.label)}</a></li>`;
})
.join("");
nav.innerHTML = `<ol class="breadcrumb mb-0">${items}</ol>`;
}
function tuneInFetchAndRender(url) {
const el = document.getElementById("tunein-results");
el.innerHTML = '<div class="loading-spinner mx-auto mt-4"></div>';
fetch(url)
.then((r) => r.json())
.then((data) => {
if (data.success) {
renderTuneInResponse(data.data);
} else {
el.innerHTML = `<div class="alert alert-danger mt-3">${escapeHtml(data.error || "Failed to load TuneIn content")}</div>`;
}
})
.catch(() => {
el.innerHTML =
'<div class="alert alert-danger mt-3">Failed to load TuneIn content. Check your connection.</div>';
});
}
function renderTuneInResponse(data) {
const el = document.getElementById("tunein-results");
if (!data || !data.bmx_sections || data.bmx_sections.length === 0) {
el.innerHTML =
'<div class="text-center text-muted py-5"><i class="bi bi-music-note display-4"></i><p class="mt-2">No results found</p></div>';
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
? `<h5 class="tunein-section-title">${escapeHtml(section.name)}</h5>`
: "";
let itemsHtml;
if (layout === "ribbon") {
itemsHtml = `<div class="tunein-ribbon">${items.map((item) => renderTuneInItem(item, "ribbon")).join("")}</div>`;
} else if (layout === "hero") {
itemsHtml = `<div class="tunein-hero">${items.map((item) => renderTuneInItem(item, "hero")).join("")}</div>`;
} else if (layout === "responsiveGrid") {
itemsHtml = `<div class="tunein-grid">${items.map((item) => renderTuneInItem(item, "grid")).join("")}</div>`;
} else {
itemsHtml = `<div class="tunein-list">${items.map((item) => renderTuneInItem(item, "list")).join("")}</div>`;
}
return `<div class="tunein-section">${titleHtml}${itemsHtml}</div>`;
}
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
? `<button class="tunein-play-btn" data-play-location="${escapeHtml(playHref)}" data-play-name="${escapeHtml(name)}" data-play-type="${escapeHtml(playType)}" data-play-art="${escapeHtml(imageUrl)}" title="Play ${escapeHtml(name)}" aria-label="Play ${escapeHtml(name)}"><i class="bi bi-play-fill"></i></button>`
: "";
const imgHtml = imageUrl
? `<img src="${escapeHtml(imageUrl)}" alt="" class="tunein-item-image" loading="lazy" onerror="this.style.display='none'">`
: `<div class="tunein-item-image tunein-item-placeholder"><i class="bi bi-music-note-beamed"></i></div>`;
if (layout === "ribbon") {
return `<div class="tunein-ribbon-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-item-label">${escapeHtml(name)}</div>${playBtn}</div>`;
}
if (layout === "hero") {
return `<div class="tunein-hero-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-hero-overlay"><div class="tunein-hero-name">${escapeHtml(name)}</div>${subtitle ? `<div class="tunein-hero-subtitle">${escapeHtml(subtitle)}</div>` : ""}</div>${playBtn ? `<div class="tunein-hero-play">${playBtn}</div>` : ""}</div>`;
}
if (layout === "grid") {
return `<div class="tunein-grid-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-item-label">${escapeHtml(name)}</div>${subtitle ? `<div class="tunein-item-subtitle">${escapeHtml(subtitle)}</div>` : ""}${playBtn ? `<div class="mt-1 text-center">${playBtn}</div>` : ""}</div>`;
}
// list / shortList / default
return `<div class="tunein-list-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-item-info"><div class="tunein-item-name">${escapeHtml(name)}</div>${subtitle ? `<div class="tunein-item-subtitle">${escapeHtml(subtitle)}</div>` : ""}</div>${isNavigable ? '<i class="bi bi-chevron-right tunein-item-chevron ms-auto"></i>' : ""}${playBtn}</div>`;
}
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]) =>
`<button class="btn btn-outline-secondary w-100 text-start mb-1" data-device-id="${escapeHtml(id)}" onclick="tuneInPlayOnDevice('${escapeHtml(id)}')"><i class="bi bi-speaker me-2"></i>${escapeHtml(dev.info?.Name || id)}</button>`,
)
.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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// 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 || "");
}
}
});
});
+33 -2
View File
@@ -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.
+480 -2
View File
@@ -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) {
+77 -4
View File
@@ -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)
}
}
@@ -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()
-6
View File
@@ -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
//
@@ -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"
}
@@ -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 lannée, nos reporters croisent des artistes du continent et dailleurs. Dans leurs maisons, dans les coulisses des concerts, les chambres dhôtel ou dans la rue se nouent des rencontres uniques où lon 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, cest une sélection de chansons qui font lactualité sur les 5 continents. DAbidjan à Caracas, de Paris à Shanghai, quest-ce qui fait vibrer la planète ? Une fois par mois, BPM vous emmène à la rencontre dun..."
},
{
"_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"
}