352 lines
No EOL
12 KiB
Go
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[:])
|
|
} |