From f276b28be7dd68709fc5e9d0295e11df2dd2fcde Mon Sep 17 00:00:00 2001 From: Daniil Tsivinsky Date: Thu, 5 Mar 2026 13:22:00 +0300 Subject: [PATCH] init --- go.mod | 3 + main.go | 212 +++++++++++++++++++++++++++++++++++++++++++ readme.md | 3 + subsonic/auth.go | 51 +++++++++++ subsonic/browsing.go | 11 +++ subsonic/error.go | 23 +++++ subsonic/http.go | 44 +++++++++ subsonic/podcasts.go | 134 +++++++++++++++++++++++++++ subsonic/response.go | 11 +++ subsonic/system.go | 18 ++++ 10 files changed, 510 insertions(+) create mode 100644 go.mod create mode 100644 main.go create mode 100644 readme.md create mode 100644 subsonic/auth.go create mode 100644 subsonic/browsing.go create mode 100644 subsonic/error.go create mode 100644 subsonic/http.go create mode 100644 subsonic/podcasts.go create mode 100644 subsonic/response.go create mode 100644 subsonic/system.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..83d4f32 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module subsonic-api + +go 1.25.0 diff --git a/main.go b/main.go new file mode 100644 index 0000000..7fbf764 --- /dev/null +++ b/main.go @@ -0,0 +1,212 @@ +package main + +import ( + "log" + "net/http" + "subsonic-api/subsonic" + "time" +) + +func main() { + mux := http.NewServeMux() + + mux.HandleFunc("GET /rest/ping", func(w http.ResponseWriter, r *http.Request) { + err := subsonic.VerifyUser(r, "daniil", "Daniil2000") + if err != nil { + subsonic.SendHTTPResponse(w, r, subsonic.ErrorResponse{ + Response: subsonic.Response{ + Status: "failed", + Version: "1.16.1", + }, + Error: subsonic.ErrorWrongAuth, + }, 401) + return + } + + subsonic.SendHTTPResponse(w, r, subsonic.PingResponse{ + Response: subsonic.Response{ + Status: "ok", + Version: "1.16.1", + }, + }, 200) + }) + + mux.HandleFunc("GET /rest/getLicense", func(w http.ResponseWriter, r *http.Request) { + subsonic.SendHTTPResponse(w, r, subsonic.LicenseResponse{ + Response: subsonic.Response{ + Status: "ok", + Version: "1.16.1", + }, + License: subsonic.License{ + Valid: true, + Email: "daniil@tsivinsky.com", + Expires: time.Now().Add(time.Hour * 24 * 365), + }, + }, 200) + }) + + mux.HandleFunc("GET /rest/getMusicFolders", func(w http.ResponseWriter, r *http.Request) { + subsonic.SendHTTPResponse(w, r, subsonic.MusicFoldersResponse{ + Response: subsonic.Response{ + Status: "ok", + Version: "1.16.1", + }, + MusicFolders: []subsonic.MusicFolder{ + { + ID: 1, + Name: "Music", + }, + { + ID: 2, + Name: "Movies", + }, + { + ID: 3, + Name: "Incoming", + }, + }, + }, 200) + }) + + mux.HandleFunc("GET /rest/getPodcasts", func(w http.ResponseWriter, r *http.Request) { + subsonic.SendHTTPResponse(w, r, subsonic.PodcastsResponse{ + Response: subsonic.Response{ + Status: "ok", + Version: "1.16.1", + }, + Podcasts: []subsonic.Podcast{ + { + ID: 1, + URL: "http://downloads.bbc.co.uk/podcasts/fivelive/drkarl/rss.xml", + Title: "Dr Karl and the Naked Scientist", + Description: "Dr Chris Smith aka The Naked Scientist with the latest news from the world of science and Dr Karl answers listeners' science questions.", + CoverArt: "pod-1", + OriginalImageURL: "http://downloads.bbc.co.uk/podcasts/fivelive/drkarl/drkarl.jpg", + Status: "completed", + Episodes: []subsonic.Episode{ + { + ID: 34, + StreamID: 523, + ChannelID: 1, + Title: "Scorpions have re-evolved eyes", + Description: "This week Dr Chris fills us in on the UK's largest free science festival, plus all this week's big scientific discoveries.", + PublishDate: time.Now(), + Status: "completed", + Parent: 11, + IsDir: false, + Year: 2011, + Genre: "Podcast", + CoverArt: 24, + Size: 78421341, + ContentType: "audio/mpeg", + Suffix: "mp3", + Duration: 3146, + Bitrate: 128, + Path: "Podcast/drkarl/20110203.mp3", + }, + }, + }, + { + ID: 3, + URL: "http://foo.bar.com/xyz.rss", + Status: "error", + ErrorMessage: "Not found.", + }, + }, + }, 200) + }) + + mux.HandleFunc("GET /rest/getNewestPodcasts", func(w http.ResponseWriter, r *http.Request) { + subsonic.SendHTTPResponse(w, r, subsonic.NewestPodcastsResponse{ + Response: subsonic.Response{ + Status: "ok", + Version: "1.16.1", + }, + NewestPodcasts: []subsonic.NewestEpisode{ + { + ID: 7389, + Parent: 7389, + IsDir: false, + Title: "Jonas Gahr Støre", + Album: "NRK – Hallo P3", + Artist: "Podcast", + Year: 2015, + CoverArt: 7389, + Size: 41808585, + ContentType: "audio/mpeg", + Suffix: "mp3", + Duration: 2619, + Bitrate: 128, + IsVideo: false, + Created: time.Now(), + ArtistID: 453, + Type: "podcast", + StreamID: 7410, + ChannelID: 17, + Description: "Jonas Gahr Støre fra Arbeiderpartiet er med i dagens partilederutspørring i Hallo P3! Han svarer på det som lytterne lurer på, alt fra bomring i Bodø til flyktningkrisen. I tillegg til dette rapporterer Reporter Silje Ese fra den greske øya Kos der det nå er flere tusen flyktninger. Og så skal vi ta farvel med et svært utrydningstruet dyr.", + Status: "completed", + PublishDate: time.Now(), + }, + }, + }, 200) + }) + + mux.HandleFunc("GET /rest/refreshPodcasts", func(w http.ResponseWriter, r *http.Request) { + subsonic.SendHTTPResponse(w, r, subsonic.RefreshPodcastsResponse{ + Response: subsonic.Response{ + Status: "ok", + Version: "1.16.1", + }, + }, 200) + }) + + mux.HandleFunc("GET /rest/createPodcastChannel", func(w http.ResponseWriter, r *http.Request) { + subsonic.SendHTTPResponse(w, r, subsonic.CreatePodcastResponse{ + Response: subsonic.Response{ + Status: "ok", + Version: "1.16.1", + }, + }, 200) + }) + + mux.HandleFunc("GET /rest/deletePodcastChannel", func(w http.ResponseWriter, r *http.Request) { + subsonic.SendHTTPResponse(w, r, subsonic.DeletePodcastResponse{ + Response: subsonic.Response{ + Status: "ok", + Version: "1.16.1", + }, + }, 200) + }) + + mux.HandleFunc("GET /rest/deletePodcastEpisode", func(w http.ResponseWriter, r *http.Request) { + subsonic.SendHTTPResponse(w, r, subsonic.DeleteEpisodeResponse{ + Response: subsonic.Response{ + Status: "ok", + Version: "1.16.1", + }, + }, 200) + }) + + mux.HandleFunc("GET /rest/downloadPodcastEpisode", func(w http.ResponseWriter, r *http.Request) { + subsonic.SendHTTPResponse(w, r, subsonic.DownloadEpisodeResponse{ + Response: subsonic.Response{ + Status: "ok", + Version: "1.16.1", + }, + }, 200) + }) + + mux.HandleFunc("GET /rest/error", func(w http.ResponseWriter, r *http.Request) { + subsonic.SendHTTPResponse(w, r, subsonic.ErrorResponse{ + Response: subsonic.Response{ + Status: "failed", + Version: "1.16.1", + }, + Error: subsonic.ErrorWrongAuth, + }, 500) + }) + + if err := http.ListenAndServe(":5000", mux); err != nil { + log.Fatalf("failed to start http server: %v", err) + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..b1cf23f --- /dev/null +++ b/readme.md @@ -0,0 +1,3 @@ +# subsonic api types + +source - https://www.subsonic.org/pages/api.jsp diff --git a/subsonic/auth.go b/subsonic/auth.go new file mode 100644 index 0000000..329a13d --- /dev/null +++ b/subsonic/auth.go @@ -0,0 +1,51 @@ +package subsonic + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "net/http" + "strings" +) + +func verifyAgainstPassword(userPassword, passwordParam string) bool { + p := passwordParam + if strings.HasPrefix(passwordParam, "enc:") { + b, err := hex.DecodeString(passwordParam) + if err != nil { + return false + } + p = string(b) + } + + return userPassword == p +} + +func verifyAgainstToken(password, token, salt string) bool { + hash := md5.Sum([]byte(password + salt)) + return hex.EncodeToString(hash[:]) == token +} + +func VerifyUser(r *http.Request, username, password string) error { + u := r.URL.Query().Get("u") + if u == "" { + return fmt.Errorf("username parameter missing") + } + + p := r.URL.Query().Get("p") + if p != "" { + ok := verifyAgainstPassword(password, p) + if !ok { + return fmt.Errorf("passwords don't match") + } + return nil + } + + t := r.URL.Query().Get("t") + s := r.URL.Query().Get("s") + if !verifyAgainstToken(password, t, s) { + return fmt.Errorf("passwords don't match") + } + + return nil +} diff --git a/subsonic/browsing.go b/subsonic/browsing.go new file mode 100644 index 0000000..df9b68d --- /dev/null +++ b/subsonic/browsing.go @@ -0,0 +1,11 @@ +package subsonic + +type MusicFolder struct { + ID int `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` +} + +type MusicFoldersResponse struct { + Response + MusicFolders []MusicFolder `xml:"musicFolders>musicFolder" json:"musicFolders"` +} diff --git a/subsonic/error.go b/subsonic/error.go new file mode 100644 index 0000000..27abcd7 --- /dev/null +++ b/subsonic/error.go @@ -0,0 +1,23 @@ +package subsonic + +type Error struct { + Code int `xml:"code,attr" json:"code"` + Message string `xml:"message,attr" json:"message"` +} + +type ErrorResponse struct { + Response + Error Error `xml:"error" json:"error"` +} + +var ( + ErrorGeneric = Error{0, "A generic error."} + ErrorRequired = Error{10, "Required parameter is missing."} + ErrorIncompatibleClient = Error{20, "Incompatible Subsonic REST protocol version. Client must upgrade."} + ErrorIncompatibleServer = Error{30, "Incompatible Subsonic REST protocol version. Server must upgrade."} + ErrorWrongAuth = Error{40, "Wrong username or password."} + ErrorLDAPTokenAuth = Error{41, "Token authentication not supported for LDAP users."} + ErrorUserUnauthorized = Error{50, "User is not authorized for the given operation."} + ErrorTrialPeriodEnded = Error{60, "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details. "} + ErrorNotFound = Error{70, "The requested data was not found."} +) diff --git a/subsonic/http.go b/subsonic/http.go new file mode 100644 index 0000000..8d6774e --- /dev/null +++ b/subsonic/http.go @@ -0,0 +1,44 @@ +package subsonic + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "net/http" +) + +func sendJSON(w http.ResponseWriter, data any, status int) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + return json.NewEncoder(w).Encode(data) +} + +func sendXML(w http.ResponseWriter, data any, status int) error { + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(status) + return xml.NewEncoder(w).Encode(data) +} + +func SendHTTPResponse(w http.ResponseWriter, r *http.Request, data any, status int) error { + format := r.URL.Query().Get("f") + if format == "" { + format = "json" + } + + resp := struct { + SubsonicResponse any `xml:"subsonic-response" json:"subsonic-response"` + }{ + SubsonicResponse: data, + } + + switch format { + case "xml": + return sendXML(w, data, status) + case "json": + return sendJSON(w, resp, status) + case "jsonp": + return sendJSON(w, resp, status) + } + + return fmt.Errorf("invalid format") +} diff --git a/subsonic/podcasts.go b/subsonic/podcasts.go new file mode 100644 index 0000000..ffc971c --- /dev/null +++ b/subsonic/podcasts.go @@ -0,0 +1,134 @@ +package subsonic + +import ( + "encoding/json" + "encoding/xml" + "time" +) + +type Episode struct { + XMLName xml.Name `xml:"episode" json:"-"` + ID int `xml:"id,attr" json:"id"` + StreamID int `xml:"streamId,attr" json:"streamId"` + ChannelID int `xml:"channelId,attr" json:"channelId"` + Title string `xml:"title,attr" json:"title"` + Description string `xml:"description,attr" json:"description"` + PublishDate time.Time `xml:"publishDate,attr" json:"publishDate"` + Status string `xml:"status,attr" json:"status"` + Parent int `xml:"parent,attr" json:"parent"` + IsDir bool `xml:"isDir,attr" json:"isDir"` + Year int `xml:"year,attr" json:"year"` + Genre string `xml:"genre,attr" json:"genre"` + CoverArt int `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"` +} + +type Podcast struct { + XMLName xml.Name `xml:"channel" json:"-"` + ID int `xml:"id,attr" json:"id"` + URL string `xml:"url,attr" json:"url"` + Title string `xml:"title,attr" json:"title"` + Description string `xml:"description,attr" json:"description"` + CoverArt string `xml:"coverArt,attr" json:"coverArt"` + OriginalImageURL string `xml:"originalImageUrl,attr" json:"originalImageUrl"` + Status string `xml:"status,attr" json:"status"` + Episodes []Episode `xml:"episode" json:"episodes"` + ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"` +} + +func (p Podcast) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if p.ErrorMessage != "" { + return e.EncodeElement(struct { + ID int `xml:"id,attr,omitempty"` + URL string `xml:"url,attr,omitempty"` + Status string `xml:"status,attr,omitempty"` + ErrorMessage string `xml:"errorMessage,attr,omitempty"` + }{ + ID: p.ID, + URL: p.URL, + Status: p.Status, + ErrorMessage: p.ErrorMessage, + }, start) + } + + type Alias Podcast + return e.EncodeElement(Alias(p), start) +} + +func (p Podcast) MarshalJSON() ([]byte, error) { + if p.ErrorMessage != "" { + return json.Marshal(struct { + ID int `xml:"id,attr,omitempty" json:"id,omitempty"` + URL string `xml:"url,attr,omitempty" json:"url,omitempty"` + Status string `xml:"status,attr,omitempty" json:"status,omitempty"` + ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"` + }{ + ID: p.ID, + URL: p.URL, + ErrorMessage: p.ErrorMessage, + }) + } + + type Alias Podcast + return json.Marshal(Alias(p)) +} + +type PodcastsResponse struct { + Response + Podcasts []Podcast `xml:"podcasts>channel" json:"podcasts"` +} + +type NewestEpisode struct { + ID int `xml:"id,attr" json:"id"` + Parent int `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"` + Year int `xml:"year,attr" json:"year"` + CoverArt int `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"` + IsVideo bool `xml:"isVideo,attr" json:"isVideo"` + Created time.Time `xml:"created,attr" json:"created"` + ArtistID int `xml:"artistId,attr" json:"artistId"` + Type string `xml:"type,attr" json:"type"` + StreamID int `xml:"streamId,attr" json:"streamId"` + ChannelID int `xml:"channelId,attr" json:"channelId"` + Description string `xml:"description,attr" json:"description"` + Status string `xml:"status,attr" json:"status"` + PublishDate time.Time `xml:"publishDate,attr" json:"publishDate"` +} + +type NewestPodcastsResponse struct { + Response + NewestPodcasts []NewestEpisode `xml:"newestPodcasts>episode" json:"newestPodcasts"` +} + +type RefreshPodcastsResponse struct { + Response +} + +type CreatePodcastResponse struct { + Response +} + +type DeletePodcastResponse struct { + Response +} + +type DeleteEpisodeResponse struct { + Response +} + +type DownloadEpisodeResponse struct { + Response +} diff --git a/subsonic/response.go b/subsonic/response.go new file mode 100644 index 0000000..a708911 --- /dev/null +++ b/subsonic/response.go @@ -0,0 +1,11 @@ +package subsonic + +import ( + "encoding/xml" +) + +type Response struct { + XMLName xml.Name `xml:"subsonic-response" json:"-"` + Status string `xml:"status,attr" json:"status"` + Version string `xml:"version,attr" json:"version"` +} diff --git a/subsonic/system.go b/subsonic/system.go new file mode 100644 index 0000000..a290fc4 --- /dev/null +++ b/subsonic/system.go @@ -0,0 +1,18 @@ +package subsonic + +import "time" + +type PingResponse struct { + Response +} + +type License struct { + Valid bool `xml:"valid,attr" json:"valid"` + Email string `xml:"email,attr" json:"email"` + Expires time.Time `xml:"licenseExpires,attr" json:"licenseExpires"` +} + +type LicenseResponse struct { + Response + License License `xml:"license" json:"license"` +}