This commit is contained in:
2026-03-05 13:22:00 +03:00
commit f276b28be7
10 changed files with 510 additions and 0 deletions

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module subsonic-api
go 1.25.0

212
main.go Normal file
View File

@@ -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)
}
}

3
readme.md Normal file
View File

@@ -0,0 +1,3 @@
# subsonic api types
source - https://www.subsonic.org/pages/api.jsp

51
subsonic/auth.go Normal file
View File

@@ -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
}

11
subsonic/browsing.go Normal file
View File

@@ -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"`
}

23
subsonic/error.go Normal file
View File

@@ -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."}
)

44
subsonic/http.go Normal file
View File

@@ -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")
}

134
subsonic/podcasts.go Normal file
View File

@@ -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
}

11
subsonic/response.go Normal file
View File

@@ -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"`
}

18
subsonic/system.go Normal file
View File

@@ -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"`
}