diff --git a/cmd/soundtouch-cli/cmd_playback.go b/cmd/soundtouch-cli/cmd_playback.go index 1d20072..4697010 100644 --- a/cmd/soundtouch-cli/cmd_playback.go +++ b/cmd/soundtouch-cli/cmd_playback.go @@ -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) } diff --git a/cmd/soundtouch-cli/cmd_preset.go b/cmd/soundtouch-cli/cmd_preset.go index d79b1f3..40167cb 100644 --- a/cmd/soundtouch-cli/cmd_preset.go +++ b/cmd/soundtouch-cli/cmd_preset.go @@ -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 } diff --git a/cmd/soundtouch-cli/common.go b/cmd/soundtouch-cli/common.go index f02de59..d104797 100644 --- a/cmd/soundtouch-cli/common.go +++ b/cmd/soundtouch-cli/common.go @@ -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: // Example: 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: + + + + + + + +` + + 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) + } +} diff --git a/docs/preset-store.md b/docs/preset-store.md index 66772dd..54fb464 100644 --- a/docs/preset-store.md +++ b/docs/preset-store.md @@ -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