Implement Spotify URL metadata extraction and HTML entity unescaping

This commit is contained in:
Tobias Gesellchen
2026-02-01 22:58:24 +01:00
parent d194cfd2a4
commit a008093775
5 changed files with 211 additions and 25 deletions
+1
View File
@@ -157,6 +157,7 @@ func printVerbosePlaybackDetails(nowPlaying *models.NowPlaying) {
// Art details
if nowPlaying.Art != nil {
fmt.Printf(" Art Image Status: %s\n", nowPlaying.Art.ArtImageStatus)
if nowPlaying.Art.URL != "" {
fmt.Printf(" Art URL: %s\n", nowPlaying.Art.URL)
}
+26 -15
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
@@ -101,26 +102,36 @@ func extractPresetParams(c *cli.Context) *presetParams {
// resolveLocationAndMetadata resolves location and fetches metadata if needed
func resolveLocationAndMetadata(params *presetParams) error {
originalLocation := params.location
resolvedSource, resolvedLocation := resolveLocation(params.source, params.location)
if resolvedLocation != params.location && (params.source == "" || params.source == "TUNEIN") {
// If location was a TuneIn URL, fetch metadata if name or artwork is missing
if params.name == "" || params.artwork == "" {
metadata, err := fetchTuneInMetadata(params.location)
if err == nil && metadata != nil {
if params.name == "" {
params.name = metadata.Name
}
if params.artwork == "" {
params.artwork = metadata.Artwork
}
}
}
}
params.source = resolvedSource
params.location = resolvedLocation
// If metadata (name or artwork) is missing, try to fetch it
if params.name == "" || params.artwork == "" {
var (
metadata *Metadata
err error
)
if params.source == "TUNEIN" && strings.Contains(originalLocation, "tunein.com/radio/") {
metadata, err = fetchTuneInMetadata(originalLocation)
} else if params.source == "SPOTIFY" && strings.Contains(originalLocation, "open.spotify.com/") {
metadata, err = fetchSpotifyMetadata(originalLocation)
}
if err == nil && metadata != nil {
if params.name == "" {
params.name = metadata.Name
}
if params.artwork == "" {
params.artwork = metadata.Artwork
}
}
}
return nil
}
+89 -10
View File
@@ -1,10 +1,13 @@
package main
import (
"encoding/base64"
"fmt"
"html"
"io"
"net"
"net/http"
"regexp"
"runtime"
"strconv"
"strings"
@@ -163,10 +166,26 @@ func resolveLocation(source, location string) (string, string) {
}
}
// Spotify URL conversion
// Example: https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD?si=YhDPWL9LRGO5whz1wLsteA
if strings.Contains(location, "open.spotify.com/") {
re := regexp.MustCompile(`https://open\.spotify\.com/([^/]+)/([^?]+)`)
matches := re.FindStringSubmatch(location)
if len(matches) >= 3 {
contentType := matches[1]
contentID := matches[2]
uri := fmt.Sprintf("spotify:%s:%s", contentType, contentID)
encodedURI := base64.StdEncoding.EncodeToString([]byte(uri))
return "SPOTIFY", "/playback/container/" + encodedURI
}
}
return source, location
}
type TuneInMetadata struct {
type Metadata struct {
Name string
Artwork string
}
@@ -175,7 +194,7 @@ var httpClient = &http.Client{
Timeout: 5 * time.Second,
}
func fetchTuneInMetadata(url string) (*TuneInMetadata, error) {
func fetchTuneInMetadata(url string) (*Metadata, error) {
if !strings.Contains(url, "tunein.com/radio/") {
return nil, fmt.Errorf("url is not a TuneIn radio URL")
}
@@ -195,20 +214,20 @@ func fetchTuneInMetadata(url string) (*TuneInMetadata, error) {
return nil, err
}
html := string(body)
metadata := &TuneInMetadata{}
rawHTML := string(body)
metadata := &Metadata{}
// Simple extraction of og:title and og:image
// Example: <meta data-react-helmet="true" property="og:title" content="WDR 2 Rheinland, 100.4 FM, Köln | Free Internet Radio | TuneIn"/>
// Example: <meta data-react-helmet="true" property="og:image" content="https://cdn-radiotime-logos.tunein.com/s213886g.png"/>
titlePrefix := `property="og:title" content="`
if idx := strings.Index(html, titlePrefix); idx != -1 {
if idx := strings.Index(rawHTML, titlePrefix); idx != -1 {
start := idx + len(titlePrefix)
end := strings.Index(html[start:], `"`)
end := strings.Index(rawHTML[start:], `"`)
if end != -1 {
title := html[start : start+end]
title := html.UnescapeString(rawHTML[start : start+end])
// Clean up title (remove ", 100.4 FM, Köln | Free Internet Radio | TuneIn")
if pipeIdx := strings.Index(title, " | "); pipeIdx != -1 {
title = title[:pipeIdx]
@@ -223,12 +242,72 @@ func fetchTuneInMetadata(url string) (*TuneInMetadata, error) {
}
imagePrefix := `property="og:image" content="`
if idx := strings.Index(html, imagePrefix); idx != -1 {
if idx := strings.Index(rawHTML, imagePrefix); idx != -1 {
start := idx + len(imagePrefix)
end := strings.Index(html[start:], `"`)
end := strings.Index(rawHTML[start:], `"`)
if end != -1 {
metadata.Artwork = html[start : start+end]
metadata.Artwork = rawHTML[start : start+end]
}
}
return metadata, nil
}
func fetchSpotifyMetadata(url string) (*Metadata, error) {
if !strings.Contains(url, "open.spotify.com/") {
return nil, fmt.Errorf("url is not a Spotify URL")
}
resp, err := httpClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*200)) // Spotify pages can be larger
if err != nil {
return nil, err
}
rawHTML := string(body)
metadata := &Metadata{}
// Simple extraction of og:title and og:image
// Example: <meta property="og:title" content="Terminal Caribe - Album by Santi &amp; Tuğçe | Spotify"
titlePrefix := `property="og:title" content="`
if idx := strings.Index(rawHTML, titlePrefix); idx != -1 {
start := idx + len(titlePrefix)
end := strings.Index(rawHTML[start:], `"`)
if end != -1 {
title := html.UnescapeString(rawHTML[start : start+end])
// Clean up title (remove " | Spotify")
if pipeIdx := strings.Index(title, " | "); pipeIdx != -1 {
title = title[:pipeIdx]
}
// Spotify often has "- Album by ..." or "- Playlist by ..."
// We might want to keep it or clean it up.
// User's TuneIn example cleaned it up.
// For now let's just keep what Spotify provides as title minus the " | Spotify" part.
metadata.Name = title
}
}
imagePrefix := `property="og:image" content="`
if idx := strings.Index(rawHTML, imagePrefix); idx != -1 {
start := idx + len(imagePrefix)
end := strings.Index(rawHTML[start:], `"`)
if end != -1 {
metadata.Artwork = rawHTML[start : start+end]
}
}
+89
View File
@@ -115,3 +115,92 @@ func TestResolveLocation(t *testing.T) {
})
}
}
func TestResolveLocationSpotify(t *testing.T) {
tests := []struct {
name string
source string
location string
expectedSource string
expectedLocation string
}{
{
name: "Spotify album URL",
source: "",
location: "https://open.spotify.com/album/6rT8yer84xoh0t17poLsmn?si=XqxdZazpTLC1ceoC8EeCuA",
expectedSource: "SPOTIFY",
expectedLocation: "/playback/container/c3BvdGlmeTphbGJ1bTo2clQ4eWVyODR4b2gwdDE3cG9Mc21u",
},
{
name: "Spotify playlist URL",
source: "",
location: "https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd",
expectedSource: "SPOTIFY",
expectedLocation: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDozN2k5ZFFaRjFEWDBYVXN1eFdIUlFk",
},
{
name: "Spotify track URL",
source: "",
location: "https://open.spotify.com/track/17GmwQ9Q3MTAz05OokmNNB?si=123",
expectedSource: "SPOTIFY",
expectedLocation: "/playback/container/c3BvdGlmeTp0cmFjazoxN0dtd1E5UTNNVEF6MDVPb2ttTk5C",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotSource, gotLocation := resolveLocation(tt.source, tt.location)
if gotSource != tt.expectedSource {
t.Errorf("resolveLocation() gotSource = %v, want %v", gotSource, tt.expectedSource)
}
if gotLocation != tt.expectedLocation {
t.Errorf("resolveLocation() gotLocation = %v, want %v", gotLocation, tt.expectedLocation)
}
})
}
}
func TestFetchSpotifyMetadata(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
html := `
<!doctype html>
<html>
<head>
<meta property="og:title" content="Terminal Caribe - Album by Santi &amp; Tuğçe | Spotify"/>
<meta property="og:image" content="https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"/>
</head>
<body></body>
</html>
`
w.WriteHeader(http.StatusOK)
w.Write([]byte(html))
}))
defer ts.Close()
// Temporarily override httpClient to use test server
oldClient := httpClient
httpClient = ts.Client()
defer func() { httpClient = oldClient }()
metadata, err := fetchSpotifyMetadata("https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD")
if err != nil {
t.Fatalf("fetchSpotifyMetadata() error = %v", err)
}
if metadata == nil {
t.Fatal("fetchSpotifyMetadata() returned nil metadata")
}
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
if metadata.Name != expectedName {
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
}
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
if metadata.Artwork != expectedArtwork {
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
}
}
+6
View File
@@ -96,6 +96,12 @@ soundtouch-cli --host 192.168.1.100 preset store \
--slot 6 \
--location "https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/"
# Store Spotify album using URL (Name and Artwork are automatically fetched)
soundtouch-cli --host 192.168.1.100 preset store \
--slot 1 \
--location "https://open.spotify.com/album/6rT8yer84xoh0t17poLsmn?si=XqxdZazpTLC1ceoC8EeCuA" \
--source-account "yourusername"
# Remove preset
soundtouch-cli --host 192.168.1.100 preset remove --slot 3