mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Implement Spotify URL metadata extraction and HTML entity unescaping
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & 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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user