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[:]) }