This commit is contained in:
Felipe M 2025-05-13 11:52:37 +02:00
commit 1a4986f294
Signed by: fmartingr
GPG key ID: CCFBC5637D4000A8
18 changed files with 3181 additions and 0 deletions

352
pkg/subsonic/client.go Normal file
View file

@ -0,0 +1,352 @@
package subsonic
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"log/slog"
"math"
"math/rand"
"net/http"
"net/url"
"path"
"strconv"
"time"
)
// Client represents a Subsonic API client
type Client struct {
baseURL string
username string
password string
version string
httpClient *http.Client
}
// New creates a new Subsonic client
func New(server, username, password, version string, timeout time.Duration) *Client {
// Create a transport with reasonable defaults
transport := &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: false, // Allow compression for efficiency
TLSHandshakeTimeout: 10 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
ForceAttemptHTTP2: false, // Avoid HTTP/2 stream errors
ResponseHeaderTimeout: 15 * time.Second,
}
return &Client{
baseURL: server,
username: username,
password: password,
version: version,
httpClient: &http.Client{
Timeout: timeout,
Transport: transport,
},
}
}
// Response is the base response from the Subsonic API
type Response struct {
XMLName xml.Name `xml:"subsonic-response" json:"-"`
Status string `xml:"status,attr" json:"status"`
Version string `xml:"version,attr" json:"version"`
Type string `xml:"-" json:"type,omitempty"`
ServerVersion string `xml:"-" json:"serverVersion,omitempty"`
OpenSubsonic bool `xml:"-" json:"openSubsonic,omitempty"`
Error *APIError `xml:"error" json:"error,omitempty"`
RandomSong *MusicList `xml:"randomSongs" json:"randomSongs,omitempty"`
}
// APIError represents an error from the Subsonic API
type APIError struct {
Code int `xml:"code,attr" json:"code"`
Message string `xml:"message,attr" json:"message"`
}
// MusicList represents a list of songs
type MusicList struct {
Song []Song `xml:"song" json:"song"`
}
// Genre represents a music genre
type Genre struct {
Name string `xml:"name,attr" json:"name"`
}
// Artist represents an artist
type Artist struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
}
// ReplayGain represents replay gain information
type ReplayGain struct {
TrackGain float64 `xml:"trackGain,attr" json:"trackGain"`
TrackPeak float64 `xml:"trackPeak,attr" json:"trackPeak"`
AlbumPeak float64 `xml:"albumPeak,attr" json:"albumPeak"`
}
// Contributor represents a contributor to a song
type Contributor struct {
Role string `xml:"role,attr" json:"role"`
Artist *Artist `xml:"artist" json:"artist"`
}
// Song represents a song in the Subsonic API
type Song struct {
ID string `xml:"id,attr" json:"id"`
ParentID string `xml:"parent,attr" json:"parent"`
IsDir bool `xml:"isDir,attr" json:"isDir"`
Title string `xml:"title,attr" json:"title"`
Album string `xml:"album,attr" json:"album"`
Artist string `xml:"artist,attr" json:"artist"`
Track int `xml:"track,attr" json:"track"`
Year int `xml:"year,attr" json:"year"`
Genre string `xml:"genre,attr" json:"genre"`
CoverArt string `xml:"coverArt,attr" json:"coverArt"`
Size int `xml:"size,attr" json:"size"`
ContentType string `xml:"contentType,attr" json:"contentType"`
Suffix string `xml:"suffix,attr" json:"suffix"`
Duration int `xml:"duration,attr" json:"duration"`
BitRate int `xml:"bitRate,attr" json:"bitRate"`
Path string `xml:"path,attr" json:"path"`
DiscNumber int `xml:"discNumber,attr" json:"discNumber"`
Created string `xml:"created,attr" json:"created"`
AlbumId string `xml:"albumId,attr" json:"albumId"`
ArtistId string `xml:"artistId,attr" json:"artistId"`
Type string `xml:"type,attr" json:"type"`
IsVideo bool `xml:"isVideo,attr" json:"isVideo"`
Bpm int `xml:"bpm,attr" json:"bpm"`
Comment string `xml:"comment,attr" json:"comment"`
SortName string `xml:"sortName,attr" json:"sortName"`
MediaType string `xml:"mediaType,attr" json:"mediaType"`
MusicBrainzId string `xml:"musicBrainzId,attr" json:"musicBrainzId"`
Genres []Genre `xml:"genres>genre" json:"genres"`
ReplayGain *ReplayGain `xml:"replayGain" json:"replayGain"`
ChannelCount int `xml:"channelCount,attr" json:"channelCount"`
SamplingRate int `xml:"samplingRate,attr" json:"samplingRate"`
BitDepth int `xml:"bitDepth,attr" json:"bitDepth"`
Moods []string `xml:"moods>mood" json:"moods"`
Artists []Artist `xml:"artists>artist" json:"artists"`
DisplayArtist string `xml:"displayArtist,attr" json:"displayArtist"`
AlbumArtists []Artist `xml:"albumArtists>artist" json:"albumArtists"`
DisplayAlbumArtist string `xml:"displayAlbumArtist,attr" json:"displayAlbumArtist"`
Contributors []Contributor `xml:"contributors>contributor" json:"contributors"`
DisplayComposer string `xml:"displayComposer,attr" json:"displayComposer"`
ExplicitStatus string `xml:"explicitStatus,attr" json:"explicitStatus"`
}
// GetRandomSongs retrieves random songs from the library
func (c *Client) GetRandomSongs(size int) ([]Song, error) {
params := url.Values{}
params.Set("size", strconv.Itoa(size))
slog.Debug("Requesting random songs from Subsonic server", "count", size)
resp := &Response{}
err := c.makeRequest("getRandomSongs", params, resp)
if err != nil {
slog.Error("Error getting random songs", "error", err)
return nil, err
}
if resp.Error != nil {
slog.Error("Subsonic API error", "code", resp.Error.Code, "message", resp.Error.Message)
return nil, fmt.Errorf("subsonic API error %d: %s", resp.Error.Code, resp.Error.Message)
}
if resp.RandomSong == nil || len(resp.RandomSong.Song) == 0 {
slog.Info("No random songs returned from Subsonic server")
return []Song{}, nil
}
slog.Debug("Successfully retrieved random songs", "count", len(resp.RandomSong.Song))
// Debug output to verify song data
for i, song := range resp.RandomSong.Song {
slog.Debug("Song details",
"index", i+1,
"id", song.ID,
"title", song.Title,
"artist", song.Artist,
"content_type", song.ContentType,
"suffix", song.Suffix,
"bit_rate", song.BitRate,
"duration", song.Duration)
}
return resp.RandomSong.Song, nil
}
// GetStreamURL returns the URL for streaming a song with processed format
func (c *Client) GetStreamURL(id string) string {
params := c.getBaseParams()
params.Set("id", id)
// Request specific format and bitrate to ensure compatibility with Discord
params.Set("format", "mp3") // Common format that most servers support
params.Set("estimateContentLength", "true") // Helps with proper buffering
params.Set("maxBitRate", "128") // Lower bitrate for better stability
params.Set("timeOffset", "0") // Start from the beginning
params.Set("size", "") // Don't resize
baseURL, _ := url.Parse(c.baseURL)
baseURL.Path = path.Join(baseURL.Path, "rest", "stream")
baseURL.RawQuery = params.Encode()
streamURL := baseURL.String()
slog.Debug("Generated stream URL", "song_id", id, "url", streamURL, "format", "mp3", "bitrate", "128")
return streamURL
}
// GetRawStreamURL returns the URL for streaming a song in its raw format
func (c *Client) GetRawStreamURL(id string) string {
params := c.getBaseParams()
params.Set("id", id)
// Don't specify format to get the raw file
params.Set("estimateContentLength", "true")
baseURL, _ := url.Parse(c.baseURL)
baseURL.Path = path.Join(baseURL.Path, "rest", "stream")
baseURL.RawQuery = params.Encode()
streamURL := baseURL.String()
slog.Debug("Generated raw stream URL", "song_id", id, "url", streamURL)
return streamURL
}
// GetCoverArtURL returns the URL for getting cover art
func (c *Client) GetCoverArtURL(id string) string {
params := c.getBaseParams()
params.Set("id", id)
baseURL, _ := url.Parse(c.baseURL)
baseURL.Path = path.Join(baseURL.Path, "rest", "getCoverArt")
baseURL.RawQuery = params.Encode()
return baseURL.String()
}
// makeRequest makes a request to the Subsonic API
func (c *Client) makeRequest(method string, additionalParams url.Values, result interface{}) error {
params := c.getBaseParams()
for k, v := range additionalParams {
params[k] = v
}
baseURL, err := url.Parse(c.baseURL)
if err != nil {
return err
}
baseURL.Path = path.Join(baseURL.Path, "rest", method)
baseURL.RawQuery = params.Encode()
fullURL := baseURL.String()
slog.Debug("Making Subsonic API request", "url", fullURL, "method", method)
// Create a request with additional headers
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
slog.Error("Error creating HTTP request", "error", err)
return err
}
// Add headers for better compatibility
req.Header.Set("User-Agent", "DiscordJukeboxBot/1.0")
req.Header.Set("Accept", "application/json, application/xml, */*")
// Execute the request
resp, err := c.httpClient.Do(req)
if err != nil {
slog.Error("HTTP request error", "error", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slog.Error("Unexpected HTTP status", "code", resp.StatusCode, "status", resp.Status)
return fmt.Errorf("unexpected status code: %d, status: %s", resp.StatusCode, resp.Status)
}
contentType := resp.Header.Get("Content-Type")
slog.Debug("Response content type", "type", contentType)
// For debugging, read the raw response
bodyBytes, _ := io.ReadAll(resp.Body)
if len(bodyBytes) < 1000 {
slog.Debug("Response body", "body", string(bodyBytes))
} else {
slog.Debug("Response body length", "length", len(bodyBytes),
"preview", string(bodyBytes[:int(math.Min(200, float64(len(bodyBytes))))]))
}
var decodeErr error
if contentType == "application/json" || contentType == "text/json" {
// For JSON, handle the "subsonic-response" wrapper
var respWrapper struct {
SubsonicResponse *Response `json:"subsonic-response"`
}
// Decode into the wrapper first
decodeErr = json.Unmarshal(bodyBytes, &respWrapper)
if decodeErr == nil && respWrapper.SubsonicResponse != nil {
// Copy the response fields to the result
resultAsResponse, ok := result.(*Response)
if ok {
*resultAsResponse = *respWrapper.SubsonicResponse
} else {
decodeErr = fmt.Errorf("expected result to be *Response, got %T", result)
}
}
} else {
// For XML, decode directly
decoder := xml.NewDecoder(bytes.NewReader(bodyBytes))
decodeErr = decoder.Decode(result)
}
if decodeErr != nil {
slog.Error("Error decoding response", "error", decodeErr,
"response_preview", string(bodyBytes[:int(math.Min(500, float64(len(bodyBytes))))]))
}
return decodeErr
}
// getBaseParams returns the base parameters for a Subsonic API request
func (c *Client) getBaseParams() url.Values {
params := url.Values{}
params.Set("u", c.username)
// Generate a random salt
salt := generateRandomString(10)
params.Set("s", salt)
// Use MD5 for password security
token := md5Hash(c.password + salt)
params.Set("t", token)
params.Set("v", c.version)
params.Set("c", "JukeboxBot")
params.Set("f", "json") // We prefer JSON, but handle XML too
return params
}
// generateRandomString generates a random string of the given length
func generateRandomString(length int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, length)
r := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := range result {
result[i] = chars[r.Intn(len(chars))]
}
return string(result)
}
// md5Hash computes the MD5 hash of a string
func md5Hash(text string) string {
hash := md5.Sum([]byte(text))
return hex.EncodeToString(hash[:])
}

145
pkg/subsonic/client_test.go Normal file
View file

@ -0,0 +1,145 @@
package subsonic
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
)
func TestParseRandomSongsJSON(t *testing.T) {
// Open the test JSON file
file, err := os.Open("testdata/random_songs_response.json")
if err != nil {
t.Fatalf("Failed to open test file: %v", err)
}
defer file.Close()
// Read the file content
jsonData, err := io.ReadAll(file)
if err != nil {
t.Fatalf("Failed to read test file: %v", err)
}
// Parse JSON into Response struct
var respWrapper struct {
SubsonicResponse Response `json:"subsonic-response"`
}
err = json.Unmarshal(jsonData, &respWrapper)
if err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
resp := respWrapper.SubsonicResponse
// Verify the Response fields
if resp.Status != "ok" {
t.Errorf("Expected status 'ok', got '%s'", resp.Status)
}
if resp.Version != "1.16.1" {
t.Errorf("Expected version '1.16.1', got '%s'", resp.Version)
}
if resp.Type != "navidrome" {
t.Errorf("Expected type 'navidrome', got '%s'", resp.Type)
}
if resp.ServerVersion != "0.55.2 (a057a680)" {
t.Errorf("Expected serverVersion '0.55.2 (a057a680)', got '%s'", resp.ServerVersion)
}
if !resp.OpenSubsonic {
t.Error("Expected openSubsonic to be true")
}
// Verify RandomSong data
if resp.RandomSong == nil {
t.Fatal("RandomSong is nil, expected data")
}
// We should have 2 songs in our test data
if len(resp.RandomSong.Song) != 2 {
t.Fatalf("Expected 2 songs, got %d", len(resp.RandomSong.Song))
}
// Check the first song
song1 := resp.RandomSong.Song[0]
if song1.ID != "WxADUtZQmq1rvWMKRteTvh" {
t.Errorf("Expected song ID 'WxADUtZQmq1rvWMKRteTvh', got '%s'", song1.ID)
}
if song1.Title != "The First Book (Extended)" {
t.Errorf("Expected song title 'The First Book (Extended)', got '%s'", song1.Title)
}
if song1.Artist != "桜庭統" {
t.Errorf("Expected artist '桜庭統', got '%s'", song1.Artist)
}
if song1.Album != "Golden Sun" {
t.Errorf("Expected album 'Golden Sun', got '%s'", song1.Album)
}
if song1.Duration != 377 {
t.Errorf("Expected duration 377, got %d", song1.Duration)
}
if song1.Path != "桜庭統/Golden Sun/01-02 - The First Book (Extended).mp3" {
t.Errorf("Expected path '桜庭統/Golden Sun/01-02 - The First Book (Extended).mp3', got '%s'", song1.Path)
}
// Check nested structures
if len(song1.Genres) != 1 {
t.Errorf("Expected 1 genre, got %d", len(song1.Genres))
} else if song1.Genres[0].Name != "Game Soundtrack" {
t.Errorf("Expected genre 'Game Soundtrack', got '%s'", song1.Genres[0].Name)
}
// Check second song
song2 := resp.RandomSong.Song[1]
if song2.ID != "1LuCYVkmVmNfmJgc8orwCi" {
t.Errorf("Expected song ID '1LuCYVkmVmNfmJgc8orwCi', got '%s'", song2.ID)
}
if song2.Title != "Divine Beast Vah Ruta Battle" {
t.Errorf("Expected song title 'Divine Beast Vah Ruta Battle', got '%s'", song2.Title)
}
if song2.Artist != "Yasuaki Iwata" {
t.Errorf("Expected artist 'Yasuaki Iwata', got '%s'", song2.Artist)
}
}
func TestMakeRequest(t *testing.T) {
// Create a test server that returns our test JSON
testFile, err := os.ReadFile("testdata/random_songs_response.json")
if err != nil {
t.Fatalf("Failed to read test file: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(testFile)
}))
defer server.Close()
// Create a client using our test server URL
client := &Client{
baseURL: server.URL,
username: "testuser",
password: "testpass",
version: "1.16.1",
httpClient: &http.Client{},
}
// Test the GetRandomSongs method which uses makeRequest
songs, err := client.GetRandomSongs(10)
if err != nil {
t.Fatalf("GetRandomSongs failed: %v", err)
}
// Verify we got some songs back
if len(songs) != 2 {
t.Errorf("Expected 2 songs, got %d", len(songs))
}
// Check that the song data was parsed correctly
if songs[0].Title != "The First Book (Extended)" {
t.Errorf("Expected song title 'The First Book (Extended)', got '%s'", songs[0].Title)
}
if songs[1].Title != "Divine Beast Vah Ruta Battle" {
t.Errorf("Expected song title 'Divine Beast Vah Ruta Battle', got '%s'", songs[1].Title)
}
}

View file

@ -0,0 +1,147 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "0.55.2 (a057a680)",
"openSubsonic": true,
"randomSongs": {
"song": [
{
"id": "WxADUtZQmq1rvWMKRteTvh",
"parent": "0xRVI3OwZhprfTzN6vs6ti",
"isDir": false,
"title": "The First Book (Extended)",
"album": "Golden Sun",
"artist": "桜庭統",
"track": 2,
"year": 2007,
"genre": "Game Soundtrack",
"coverArt": "mf-WxADUtZQmq1rvWMKRteTvh_65045c88",
"size": 3910630,
"contentType": "audio/mpeg",
"suffix": "mp3",
"duration": 377,
"bitRate": 80,
"path": "桜庭統/Golden Sun/01-02 - The First Book (Extended).mp3",
"discNumber": 1,
"created": "2025-05-12T18:22:47.912430557Z",
"albumId": "0xRVI3OwZhprfTzN6vs6ti",
"artistId": "6I2Nnx3eA2xnsUGwQTtr3D",
"type": "music",
"isVideo": false,
"bpm": 0,
"comment": "www.sittingonclouds.net",
"sortName": "the first book (extended)",
"mediaType": "song",
"musicBrainzId": "46d99fd8-4b84-4202-aeeb-56ffd03bc4aa",
"genres": [
{
"name": "Game Soundtrack"
}
],
"replayGain": {
"trackPeak": 1,
"albumPeak": 1
},
"channelCount": 2,
"samplingRate": 32000,
"bitDepth": 0,
"moods": [],
"artists": [
{
"id": "6I2Nnx3eA2xnsUGwQTtr3D",
"name": "桜庭統"
}
],
"displayArtist": "桜庭統",
"albumArtists": [
{
"id": "6I2Nnx3eA2xnsUGwQTtr3D",
"name": "桜庭統"
}
],
"displayAlbumArtist": "桜庭統",
"contributors": [
{
"role": "composer",
"artist": {
"id": "7ALBQiGRFZDSjXkrHPK9xX",
"name": "Motoi Sakuraba"
}
}
],
"displayComposer": "Motoi Sakuraba",
"explicitStatus": ""
},
{
"id": "1LuCYVkmVmNfmJgc8orwCi",
"parent": "092dQuAiPh55hzAd7Y06lM",
"isDir": false,
"title": "Divine Beast Vah Ruta Battle",
"album": "The Legend of Zelda: Breath of the Wild Original Soundtrack",
"artist": "Yasuaki Iwata",
"track": 8,
"year": 2018,
"genre": "Game Soundtrack",
"coverArt": "mf-1LuCYVkmVmNfmJgc8orwCi_65045c9c",
"size": 12278801,
"contentType": "audio/flac",
"suffix": "flac",
"duration": 119,
"bitRate": 819,
"path": "Manaka Kataoka, Yasuaki Iwata & Hajime Wakai/The Legend of Zelda: Breath of the Wild Original Soundtrack/02-08 - Divine Beast Vah Ruta Battle.flac",
"discNumber": 2,
"created": "2025-05-12T18:22:47.166382716Z",
"albumId": "092dQuAiPh55hzAd7Y06lM",
"artistId": "7aGQqm0XpMOc4e4rEgj5iV",
"type": "music",
"isVideo": false,
"bpm": 0,
"comment": "sittingoncloudsost.com/ost",
"sortName": "divine beast vah ruta battle",
"mediaType": "song",
"musicBrainzId": "e2102713-f587-4eae-9538-2fc019e1c440",
"genres": [
{
"name": "Game Soundtrack"
}
],
"replayGain": {
"trackPeak": 1,
"albumPeak": 1
},
"channelCount": 2,
"samplingRate": 44100,
"bitDepth": 16,
"moods": [],
"artists": [
{
"id": "7aGQqm0XpMOc4e4rEgj5iV",
"name": "Yasuaki Iwata"
}
],
"displayArtist": "Yasuaki Iwata",
"albumArtists": [
{
"id": "600iKBbJhoZWOE782AHR14",
"name": "Manaka Kataoka, Yasuaki Iwata & Hajime Wakai"
}
],
"displayAlbumArtist": "Manaka Kataoka, Yasuaki Iwata & Hajime Wakai",
"contributors": [
{
"role": "composer",
"artist": {
"id": "7aGQqm0XpMOc4e4rEgj5iV",
"name": "Yasuaki Iwata"
}
}
],
"displayComposer": "Yasuaki Iwata",
"explicitStatus": ""
}
]
}
}
}