initial
This commit is contained in:
commit
1a4986f294
18 changed files with 3181 additions and 0 deletions
352
pkg/subsonic/client.go
Normal file
352
pkg/subsonic/client.go
Normal 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[:])
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue