discord-jukebox-bot/pkg/subsonic/client.go
2025-05-13 11:52:37 +02:00

352 lines
No EOL
12 KiB
Go

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