diff --git a/.gitignore b/.gitignore
index 9d3b79fb..dd2b58d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,4 @@ gonicembed
.vscode
*.swp
.tags*
+*.test
diff --git a/db/migrations.go b/db/migrations.go
index a663320e..b0008e34 100644
--- a/db/migrations.go
+++ b/db/migrations.go
@@ -42,6 +42,7 @@ func (db *DB) Migrate(ctx MigrationContext) error {
construct(ctx, "202202121809", migrateAlbumRootDirAgain),
construct(ctx, "202202241218", migratePublicPlaylist),
construct(ctx, "202204270903", migratePodcastDropUserID),
+ construct(ctx, "202206011628", migrateInternetRadioStations),
}
return gormigrate.
@@ -356,3 +357,9 @@ func migratePodcastDropUserID(tx *gorm.DB, _ MigrationContext) error {
return nil
}
+func migrateInternetRadioStations(tx *gorm.DB, _ MigrationContext) error {
+ return tx.AutoMigrate(
+ InternetRadioStation{},
+ ).
+ Error
+}
diff --git a/db/model.go b/db/model.go
index 867b0066..de45a871 100644
--- a/db/model.go
+++ b/db/model.go
@@ -396,3 +396,14 @@ type Bookmark struct {
CreatedAt time.Time
UpdatedAt time.Time
}
+
+type InternetRadioStation struct {
+ ID int `gorm:"primary_key"`
+ StreamURL string
+ Name string
+ HomepageURL string
+}
+
+func (ir *InternetRadioStation) SID() *specid.ID {
+ return &specid.ID{Type: specid.InternetRadioStation, Value: ir.ID}
+}
diff --git a/server/assets/pages/home.tmpl b/server/assets/pages/home.tmpl
index 6293760d..3303e909 100644
--- a/server/assets/pages/home.tmpl
+++ b/server/assets/pages/home.tmpl
@@ -208,6 +208,38 @@
{{ end }}
+{{ if .User.IsAdmin }}
+
playlists
diff --git a/server/ctrladmin/ctrl.go b/server/ctrladmin/ctrl.go
index 2298092b..d86d0a19 100644
--- a/server/ctrladmin/ctrl.go
+++ b/server/ctrladmin/ctrl.go
@@ -123,6 +123,7 @@ type templateData struct {
SelectedUser *db.User
Podcasts []*db.Podcast
+ InternetRadioStations []*db.InternetRadioStation
}
type Response struct {
diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go
index 68b74e90..269e1e86 100644
--- a/server/ctrladmin/handlers.go
+++ b/server/ctrladmin/handlers.go
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/http"
+ "net/url"
"strconv"
"time"
@@ -73,6 +74,9 @@ func (c *Controller) ServeHome(r *http.Request) *Response {
// podcasts box
c.DB.Find(&data.Podcasts)
+ // internet radio box
+ c.DB.Find(&data.InternetRadioStations)
+
return &Response{
template: "home.tmpl",
data: data,
@@ -400,7 +404,7 @@ func (c *Controller) ServePodcastAddDo(r *http.Request) *Response {
flashW: []string{fmt.Sprintf("could not create feed: %v", err)},
}
}
- if _, err = c.Podcasts.AddNewPodcast(rssURL, feed); err != nil {
+ if _, err := c.Podcasts.AddNewPodcast(rssURL, feed); err != nil {
return &Response{
redirect: "/admin/home",
flashW: []string{fmt.Sprintf("could not create feed: %v", err)},
@@ -464,3 +468,105 @@ func (c *Controller) ServePodcastDeleteDo(r *http.Request) *Response {
redirect: "/admin/home",
}
}
+
+func (c *Controller) ServeInternetRadioStationAddDo(r *http.Request) *Response {
+ streamURL := r.FormValue("streamURL")
+ name := r.FormValue("name")
+ homepageURL := r.FormValue("homepageURL")
+
+ if name == "" {
+ return &Response{redirect: "/admin/home", flashW: []string{"no name provided"}}
+ }
+
+ if _, err := url.ParseRequestURI(streamURL); err != nil {
+ return &Response{redirect: "/admin/home", flashW: []string{fmt.Sprintf("bad stream URL provided: %v", err)}}
+ }
+
+ if homepageURL != "" {
+ if _, err := url.ParseRequestURI(homepageURL); err != nil {
+ return &Response{redirect: "/admin/home", flashW: []string{fmt.Sprintf("bad homepage URL provided: %v", err)}}
+ }
+ }
+
+ var station db.InternetRadioStation
+ station.StreamURL = streamURL
+ station.Name = name
+ station.HomepageURL = homepageURL
+ if err := c.DB.Save(&station).Error; err != nil {
+ return &Response{code: 500, err: fmt.Sprintf("error saving station: %v", err)}
+ }
+
+ return &Response{
+ redirect: "/admin/home",
+ }
+}
+
+func (c *Controller) ServeInternetRadioStationUpdateDo(r *http.Request) *Response {
+ stationID, err := strconv.Atoi(r.URL.Query().Get("id"))
+ if err != nil {
+ return &Response{code: 400, err: "please provide a valid internet radio station id"}
+ }
+
+ streamURL := r.FormValue("streamURL")
+ name := r.FormValue("name")
+ homepageURL := r.FormValue("homepageURL")
+
+ if name == "" {
+ return &Response{
+ redirect: "/admin/home",
+ flashW: []string{"no name provided"},
+ }
+ }
+
+ if _, err := url.ParseRequestURI(streamURL); err != nil {
+ return &Response{
+ redirect: "/admin/home",
+ flashW: []string{fmt.Sprintf("bad stream URL provided: %v", err)},
+ }
+ }
+
+ if homepageURL != "" {
+ if _, err := url.ParseRequestURI(homepageURL); err != nil {
+ return &Response{
+ redirect: "/admin/home",
+ flashW: []string{fmt.Sprintf("bad homepage URL provided: %v", err)},
+ }
+ }
+ }
+
+ var station db.InternetRadioStation
+ if err := c.DB.Where("id=?", stationID).First(&station).Error; err != nil {
+ return &Response{code: 404, err: fmt.Sprintf("find station by id: %v", err)}
+ }
+
+ station.StreamURL = streamURL
+ station.Name = name
+ station.HomepageURL = homepageURL
+ if err := c.DB.Save(&station).Error; err != nil {
+ return &Response{code: 500, err: "please provide a valid internet radio station id"}
+ }
+
+ return &Response{
+ redirect: "/admin/home",
+ }
+}
+
+func (c *Controller) ServeInternetRadioStationDeleteDo(r *http.Request) *Response {
+ stationID, err := strconv.Atoi(r.URL.Query().Get("id"))
+ if err != nil {
+ return &Response{code: 400, err: "please provide a valid internet radio station id"}
+ }
+
+ var station db.InternetRadioStation
+ if err := c.DB.Where("id=?", stationID).First(&station).Error; err != nil {
+ return &Response{code: 404, err: fmt.Sprintf("find station by id: %v", err)}
+ }
+
+ if err := c.DB.Where("id=?", stationID).Delete(&db.InternetRadioStation{}).Error; err != nil {
+ return &Response{code: 500, err: fmt.Sprintf("deleting radio station: %v", err)}
+ }
+
+ return &Response{
+ redirect: "/admin/home",
+ }
+}
diff --git a/server/ctrlsubsonic/ctrl_test.go b/server/ctrlsubsonic/ctrl_test.go
index 63594430..6305523d 100644
--- a/server/ctrlsubsonic/ctrl_test.go
+++ b/server/ctrlsubsonic/ctrl_test.go
@@ -69,6 +69,15 @@ func makeHTTPMock(query url.Values) (*httptest.ResponseRecorder, *http.Request)
return rr, req
}
+func makeHTTPMockWithAdmin(query url.Values) (*httptest.ResponseRecorder, *http.Request) {
+ rr, req := makeHTTPMock(query)
+ ctx := req.Context()
+ ctx = context.WithValue(ctx, CtxUser, &db.User{IsAdmin: true})
+ req = req.WithContext(ctx)
+
+ return rr, req
+}
+
func serveRaw(t *testing.T, contr *Controller, h handlerSubsonicRaw, rr *httptest.ResponseRecorder, req *http.Request) {
type middleware func(http.Handler) http.Handler
middlewares := []middleware{
diff --git a/server/ctrlsubsonic/handlers_internet_radio.go b/server/ctrlsubsonic/handlers_internet_radio.go
new file mode 100644
index 00000000..7927a49a
--- /dev/null
+++ b/server/ctrlsubsonic/handlers_internet_radio.go
@@ -0,0 +1,131 @@
+package ctrlsubsonic
+
+import (
+ "net/http"
+ "net/url"
+
+ "go.senan.xyz/gonic/db"
+ "go.senan.xyz/gonic/server/ctrlsubsonic/params"
+ "go.senan.xyz/gonic/server/ctrlsubsonic/spec"
+)
+
+func (c *Controller) ServeGetInternetRadioStations(r *http.Request) *spec.Response {
+ var stations []*db.InternetRadioStation
+ if err := c.DB.Find(&stations).Error; err != nil {
+ return spec.NewError(0, "find stations: %v", err)
+ }
+ sub := spec.NewResponse()
+ sub.InternetRadioStations = &spec.InternetRadioStations{
+ List: make([]*spec.InternetRadioStation, len(stations)),
+ }
+ for i, station := range stations {
+ sub.InternetRadioStations.List[i] = spec.NewInternetRadioStation(station)
+ }
+ return sub
+}
+
+func (c *Controller) ServeCreateInternetRadioStation(r *http.Request) *spec.Response {
+ user := r.Context().Value(CtxUser).(*db.User)
+ if !user.IsAdmin {
+ return spec.NewError(50, "user not admin")
+ }
+
+ params := r.Context().Value(CtxParams).(params.Params)
+
+ streamURL, err := params.Get("streamUrl")
+ if err != nil {
+ return spec.NewError(10, "no stream URL provided: %v", err)
+ }
+ if _, err := url.ParseRequestURI(streamURL); err != nil {
+ return spec.NewError(70, "bad stream URL provided: %v", err)
+ }
+ name, err := params.Get("name")
+ if err != nil {
+ return spec.NewError(10, "no name provided: %v", err)
+ }
+ homepageURL, err := params.Get("homepageUrl")
+ if err == nil {
+ if _, err := url.ParseRequestURI(homepageURL); err != nil {
+ return spec.NewError(70, "bad homepage URL provided: %v", err)
+ }
+ }
+
+ var station db.InternetRadioStation
+ station.StreamURL = streamURL
+ station.Name = name
+ station.HomepageURL = homepageURL
+
+ if err := c.DB.Save(&station).Error; err != nil {
+ return spec.NewError(0, "save station: %v", err)
+ }
+
+ return spec.NewResponse()
+}
+
+func (c *Controller) ServeUpdateInternetRadioStation(r *http.Request) *spec.Response {
+ user := r.Context().Value(CtxUser).(*db.User)
+ if !user.IsAdmin {
+ return spec.NewError(50, "user not admin")
+ }
+ params := r.Context().Value(CtxParams).(params.Params)
+
+ stationID, err := params.GetID("id")
+ if err != nil {
+ return spec.NewError(10, "no id provided: %v", err)
+ }
+ streamURL, err := params.Get("streamUrl")
+ if err != nil {
+ return spec.NewError(10, "no stream URL provided: %v", err)
+ }
+ if _, err = url.ParseRequestURI(streamURL); err != nil {
+ return spec.NewError(70, "bad stream URL provided: %v", err)
+ }
+ name, err := params.Get("name")
+ if err != nil {
+ return spec.NewError(10, "no name provided: %v", err)
+ }
+ homepageURL, err := params.Get("homepageUrl")
+ if err == nil {
+ if _, err := url.ParseRequestURI(homepageURL); err != nil {
+ return spec.NewError(70, "bad homepage URL provided: %v", err)
+ }
+ }
+
+ var station db.InternetRadioStation
+ if err := c.DB.Where("id=?", stationID.Value).First(&station).Error; err != nil {
+ return spec.NewError(70, "id not found: %v", err)
+ }
+
+ station.StreamURL = streamURL
+ station.Name = name
+ station.HomepageURL = homepageURL
+
+ if err := c.DB.Save(&station).Error; err != nil {
+ return spec.NewError(0, "save station: %v", err)
+ }
+ return spec.NewResponse()
+}
+
+func (c *Controller) ServeDeleteInternetRadioStation(r *http.Request) *spec.Response {
+ user := r.Context().Value(CtxUser).(*db.User)
+ if !user.IsAdmin {
+ return spec.NewError(50, "user not admin")
+ }
+ params := r.Context().Value(CtxParams).(params.Params)
+
+ stationID, err := params.GetID("id")
+ if err != nil {
+ return spec.NewError(10, "no id provided: %v", err)
+ }
+
+ var station db.InternetRadioStation
+ if err := c.DB.Where("id=?", stationID.Value).First(&station).Error; err != nil {
+ return spec.NewError(70, "id not found: %v", err)
+ }
+
+ if err := c.DB.Delete(&station).Error; err != nil {
+ return spec.NewError(70, "id not found: %v", err)
+ }
+
+ return spec.NewResponse()
+}
diff --git a/server/ctrlsubsonic/handlers_internet_radio_test.go b/server/ctrlsubsonic/handlers_internet_radio_test.go
new file mode 100644
index 00000000..18a0b4ff
--- /dev/null
+++ b/server/ctrlsubsonic/handlers_internet_radio_test.go
@@ -0,0 +1,380 @@
+package ctrlsubsonic
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "go.senan.xyz/gonic/server/ctrlsubsonic/spec"
+ "go.senan.xyz/gonic/server/ctrlsubsonic/specid"
+)
+
+const station1ID = "ir-1"
+
+var station1IDT = specid.ID{Type: specid.InternetRadioStation, Value: 1}
+
+const (
+ station1StreamURL = "http://lyd.nrk.no/nrk_radio_p1_ostlandssendingen_mp3_m"
+ station1Name = "NRK P1"
+ station1HomepageURL = "http://www.nrk.no/p1"
+)
+
+const station2ID = "ir-2"
+
+var station2IDT = specid.ID{Type: specid.InternetRadioStation, Value: 2}
+
+const (
+ station2StreamURL = "http://lyd.nrk.no/nrk_radio_p2_mp3_m"
+ station2Name = "NRK P2"
+ station2HomepageURL = "http://p3.no"
+)
+
+const (
+ newstation1StreamURL = "http://media.kcrw.com/pls/kcrwmusic.pls"
+ newstation1Name = "KCRW Eclectic"
+ newstation1HomepageURL = "https://www.kcrw.com/music/shows/eclectic24"
+)
+
+const newstation2StreamURL = "http://media.kcrw.com/pls/kcrwsantabarbara.pls"
+const newstation2Name = "KCRW Santa Barbara"
+
+const station3ID = "ir-3"
+
+const notAURL = "not_a_url"
+
+func TestInternetRadio(t *testing.T) {
+ t.Parallel()
+
+ contr := makeController(t)
+ t.Run("TestInternetRadioInitialEmpty", func(t *testing.T) { testInternetRadioInitialEmpty(t, contr) })
+ t.Run("TestInternetRadioBadCreates", func(t *testing.T) { testInternetRadioBadCreates(t, contr) })
+ t.Run("TestInternetRadioInitialAdds", func(t *testing.T) { testInternetRadioInitialAdds(t, contr) })
+ t.Run("TestInternetRadioUpdateHomepage", func(t *testing.T) { testInternetRadioUpdateHomepage(t, contr) })
+ t.Run("TestInternetRadioNotAdmin", func(t *testing.T) { testInternetRadioNotAdmin(t, contr) })
+ t.Run("TestInternetRadioUpdates", func(t *testing.T) { testInternetRadioUpdates(t, contr) })
+ t.Run("TestInternetRadioDeletes", func(t *testing.T) { testInternetRadioDeletes(t, contr) })
+}
+
+func runTestCase(t *testing.T, contr *Controller, h handlerSubsonic, q url.Values, admin bool) *spec.SubsonicResponse {
+ var rr *httptest.ResponseRecorder
+ var req *http.Request
+
+ if admin {
+ rr, req = makeHTTPMockWithAdmin(q)
+ } else {
+ rr, req = makeHTTPMock(q)
+ }
+ contr.H(h).ServeHTTP(rr, req)
+ body := rr.Body.String()
+ if status := rr.Code; status != http.StatusOK {
+ t.Fatalf("didn't give a 200\n%s", body)
+ }
+
+ var response spec.SubsonicResponse
+ if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
+ switch ty := err.(type) {
+ case *json.SyntaxError:
+ jsn := body[0:ty.Offset]
+ jsn += "<--(Invalid Character)"
+ t.Fatalf("invalid character at offset %v\n %s", ty.Offset, jsn)
+ case *json.UnmarshalTypeError:
+ jsn := body[0:ty.Offset]
+ jsn += "<--(Invalid Type)"
+ t.Fatalf("invalid type at offset %v\n %s", ty.Offset, jsn)
+ default:
+ t.Fatalf("json unmarshal failed: %s", err.Error())
+ }
+ }
+
+ return &response
+}
+
+func checkSuccess(t *testing.T, response *spec.SubsonicResponse) {
+ t.Helper()
+
+ if response.Response.Status != "ok" {
+ t.Fatal("didn't return ok status")
+ }
+}
+
+func checkError(t *testing.T, response *spec.SubsonicResponse, code int) {
+ t.Helper()
+
+ if response.Response.Status != "failed" {
+ t.Fatal("didn't return failed status")
+ }
+ if response.Response.Error.Code != code {
+ t.Fatal("returned wrong error code")
+ }
+}
+
+func checkMissingParameter(t *testing.T, response *spec.SubsonicResponse) {
+ t.Helper()
+ checkError(t, response, 10)
+}
+
+func checkBadParameter(t *testing.T, response *spec.SubsonicResponse) {
+ t.Helper()
+ checkError(t, response, 70)
+}
+
+func checkNotAdmin(t *testing.T, response *spec.SubsonicResponse) {
+ t.Helper()
+ checkError(t, response, 50)
+}
+
+func testInternetRadioBadCreates(t *testing.T, contr *Controller) {
+ var response *spec.SubsonicResponse
+
+ // no parameters
+ response = runTestCase(t, contr, contr.ServeCreateInternetRadioStation, url.Values{}, true)
+ checkMissingParameter(t, response)
+
+ // just one required parameter
+ response = runTestCase(t, contr, contr.ServeCreateInternetRadioStation,
+ url.Values{"streamUrl": {station1StreamURL}}, true)
+ checkMissingParameter(t, response)
+
+ response = runTestCase(t, contr, contr.ServeCreateInternetRadioStation,
+ url.Values{"name": {station1Name}}, true)
+ checkMissingParameter(t, response)
+
+ // bad URLs
+ response = runTestCase(t, contr, contr.ServeCreateInternetRadioStation,
+ url.Values{"streamUrl": {station1StreamURL}, "name": {station1Name}, "homepageUrl": {notAURL}}, true)
+ checkBadParameter(t, response)
+
+ response = runTestCase(t, contr, contr.ServeCreateInternetRadioStation,
+ url.Values{"streamUrl": {notAURL}, "name": {station1Name}, "homepageUrl": {station1HomepageURL}}, true)
+ checkBadParameter(t, response)
+
+ // check for empty get after
+ response = runTestCase(t, contr, contr.ServeGetInternetRadioStations, url.Values{}, false) // no need to be admin
+ checkSuccess(t, response)
+
+ if (response.Response.InternetRadioStations == nil) || (len(response.Response.InternetRadioStations.List) != 0) {
+ t.Fatal("didn't return empty stations")
+ }
+}
+
+func testInternetRadioInitialEmpty(t *testing.T, contr *Controller) {
+ // check for empty get on new DB
+ response := runTestCase(t, contr, contr.ServeGetInternetRadioStations, url.Values{}, false) // no need to be admin
+ checkSuccess(t, response)
+
+ if (response.Response.InternetRadioStations == nil) || (len(response.Response.InternetRadioStations.List) != 0) {
+ t.Fatal("didn't return empty stations")
+ }
+}
+
+func testInternetRadioInitialAdds(t *testing.T, contr *Controller) {
+ // successful adds and read back
+ response := runTestCase(t, contr, contr.ServeCreateInternetRadioStation,
+ url.Values{"streamUrl": {station1StreamURL}, "name": {station1Name}, "homepageUrl": {station1HomepageURL}}, true)
+ checkSuccess(t, response)
+
+ response = runTestCase(t, contr, contr.ServeCreateInternetRadioStation,
+ url.Values{"streamUrl": {station2StreamURL}, "name": {station2Name}}, true) // NOTE: no homepage Url
+ checkSuccess(t, response)
+
+ response = runTestCase(t, contr, contr.ServeGetInternetRadioStations, url.Values{}, false) // no need to be admin
+ checkSuccess(t, response)
+
+ if response.Response.InternetRadioStations == nil {
+ t.Fatal("didn't return stations")
+ }
+ if len(response.Response.InternetRadioStations.List) != 2 {
+ t.Fatal("wrong number of stations")
+ }
+ if (*response.Response.InternetRadioStations.List[0].ID != station1IDT) ||
+ (response.Response.InternetRadioStations.List[0].StreamURL != station1StreamURL) ||
+ (response.Response.InternetRadioStations.List[0].Name != station1Name) ||
+ (response.Response.InternetRadioStations.List[0].HomepageURL != station1HomepageURL) ||
+ (*response.Response.InternetRadioStations.List[1].ID != station2IDT) ||
+ (response.Response.InternetRadioStations.List[1].StreamURL != station2StreamURL) ||
+ (response.Response.InternetRadioStations.List[1].Name != station2Name) ||
+ (response.Response.InternetRadioStations.List[1].HomepageURL != "") {
+ t.Fatal("bad data")
+ }
+}
+
+func testInternetRadioUpdateHomepage(t *testing.T, contr *Controller) {
+ // update empty homepage URL without other parameters (fails)
+ response := runTestCase(t, contr, contr.ServeUpdateInternetRadioStation,
+ url.Values{"id": {station2ID}, "homepageUrl": {station2HomepageURL}}, true)
+ checkMissingParameter(t, response)
+
+ // update empty homepage URL properly and read back
+ response = runTestCase(t, contr, contr.ServeUpdateInternetRadioStation,
+ url.Values{"id": {station2ID}, "streamUrl": {station2StreamURL}, "name": {station2Name}, "homepageUrl": {station2HomepageURL}}, true)
+ checkSuccess(t, response)
+
+ response = runTestCase(t, contr, contr.ServeGetInternetRadioStations, url.Values{}, false) // no need to be admin
+ checkSuccess(t, response)
+
+ if response.Response.InternetRadioStations == nil {
+ t.Fatal("didn't return stations")
+ }
+ if len(response.Response.InternetRadioStations.List) != 2 {
+ t.Fatal("wrong number of stations")
+ }
+ if (*response.Response.InternetRadioStations.List[0].ID != station1IDT) ||
+ (response.Response.InternetRadioStations.List[0].StreamURL != station1StreamURL) ||
+ (response.Response.InternetRadioStations.List[0].Name != station1Name) ||
+ (response.Response.InternetRadioStations.List[0].HomepageURL != station1HomepageURL) ||
+ (*response.Response.InternetRadioStations.List[1].ID != station2IDT) ||
+ (response.Response.InternetRadioStations.List[1].StreamURL != station2StreamURL) ||
+ (response.Response.InternetRadioStations.List[1].Name != station2Name) ||
+ (response.Response.InternetRadioStations.List[1].HomepageURL != station2HomepageURL) {
+ t.Fatal("bad data")
+ }
+}
+
+func testInternetRadioNotAdmin(t *testing.T, contr *Controller) {
+ // create, update, delete w/o admin privileges (fails and does not modify data)
+ response := runTestCase(t, contr, contr.ServeCreateInternetRadioStation,
+ url.Values{"streamUrl": {station1StreamURL}, "name": {station1Name}, "homepageUrl": {station1HomepageURL}}, false)
+ checkNotAdmin(t, response)
+
+ response = runTestCase(t, contr, contr.ServeUpdateInternetRadioStation,
+ url.Values{"id": {station1ID}, "streamUrl": {newstation1StreamURL}, "name": {newstation1Name}, "homepageUrl": {newstation1HomepageURL}}, false)
+ checkNotAdmin(t, response)
+
+ response = runTestCase(t, contr, contr.ServeDeleteInternetRadioStation,
+ url.Values{"id": {station1ID}}, false)
+ checkNotAdmin(t, response)
+
+ response = runTestCase(t, contr, contr.ServeGetInternetRadioStations, url.Values{}, false) // no need to be admin
+ checkSuccess(t, response)
+
+ if response.Response.InternetRadioStations == nil {
+ t.Fatal("didn't return stations")
+ }
+ if len(response.Response.InternetRadioStations.List) != 2 {
+ t.Fatal("wrong number of stations")
+ }
+ if (*response.Response.InternetRadioStations.List[0].ID != station1IDT) ||
+ (response.Response.InternetRadioStations.List[0].StreamURL != station1StreamURL) ||
+ (response.Response.InternetRadioStations.List[0].Name != station1Name) ||
+ (response.Response.InternetRadioStations.List[0].HomepageURL != station1HomepageURL) ||
+ (*response.Response.InternetRadioStations.List[1].ID != station2IDT) ||
+ (response.Response.InternetRadioStations.List[1].StreamURL != station2StreamURL) ||
+ (response.Response.InternetRadioStations.List[1].Name != station2Name) ||
+ (response.Response.InternetRadioStations.List[1].HomepageURL != station2HomepageURL) {
+ t.Fatal("bad data")
+ }
+}
+
+func testInternetRadioUpdates(t *testing.T, contr *Controller) {
+ // replace station 1 and read back
+ response := runTestCase(t, contr, contr.ServeUpdateInternetRadioStation,
+ url.Values{"id": {station1ID}, "streamUrl": {newstation1StreamURL}, "name": {newstation1Name}, "homepageUrl": {newstation1HomepageURL}}, true)
+
+ checkSuccess(t, response)
+ response = runTestCase(t, contr, contr.ServeGetInternetRadioStations, url.Values{}, false) // no need to be admin
+ checkSuccess(t, response)
+
+ if response.Response.InternetRadioStations == nil {
+ t.Fatal("didn't return stations")
+ }
+ if len(response.Response.InternetRadioStations.List) != 2 {
+ t.Fatal("wrong number of stations")
+ }
+ if (*response.Response.InternetRadioStations.List[0].ID != station1IDT) ||
+ (response.Response.InternetRadioStations.List[0].StreamURL != newstation1StreamURL) ||
+ (response.Response.InternetRadioStations.List[0].Name != newstation1Name) ||
+ (response.Response.InternetRadioStations.List[0].HomepageURL != newstation1HomepageURL) ||
+ (*response.Response.InternetRadioStations.List[1].ID != station2IDT) ||
+ (response.Response.InternetRadioStations.List[1].StreamURL != station2StreamURL) ||
+ (response.Response.InternetRadioStations.List[1].Name != station2Name) ||
+ (response.Response.InternetRadioStations.List[1].HomepageURL != station2HomepageURL) {
+ t.Fatal("bad data")
+ }
+
+ // update station 2 but without homepage URL and read back
+ response = runTestCase(t, contr, contr.ServeUpdateInternetRadioStation,
+ url.Values{"id": {station2ID}, "streamUrl": {newstation2StreamURL}, "name": {newstation2Name}}, true)
+ checkSuccess(t, response)
+
+ response = runTestCase(t, contr, contr.ServeGetInternetRadioStations, url.Values{}, false) // no need to be admin
+ checkSuccess(t, response)
+
+ if response.Response.InternetRadioStations == nil {
+ t.Fatal("didn't return stations")
+ }
+ if len(response.Response.InternetRadioStations.List) != 2 {
+ t.Fatal("wrong number of stations")
+ }
+ if (*response.Response.InternetRadioStations.List[0].ID != station1IDT) ||
+ (response.Response.InternetRadioStations.List[0].StreamURL != newstation1StreamURL) ||
+ (response.Response.InternetRadioStations.List[0].Name != newstation1Name) ||
+ (response.Response.InternetRadioStations.List[0].HomepageURL != newstation1HomepageURL) ||
+ (*response.Response.InternetRadioStations.List[1].ID != station2IDT) ||
+ (response.Response.InternetRadioStations.List[1].StreamURL != newstation2StreamURL) ||
+ (response.Response.InternetRadioStations.List[1].Name != newstation2Name) ||
+ (response.Response.InternetRadioStations.List[1].HomepageURL != "") {
+ t.Fatal("bad data")
+ }
+}
+
+func testInternetRadioDeletes(t *testing.T, contr *Controller) {
+ // delete non-existent station 3 (fails and does not modify data)
+ response := runTestCase(t, contr, contr.ServeDeleteInternetRadioStation,
+ url.Values{"id": {station3ID}}, true)
+ checkBadParameter(t, response)
+
+ response = runTestCase(t, contr, contr.ServeGetInternetRadioStations, url.Values{}, false) // no need to be admin
+ checkSuccess(t, response)
+
+ if response.Response.InternetRadioStations == nil {
+ t.Fatal("didn't return stations")
+ }
+ if len(response.Response.InternetRadioStations.List) != 2 {
+ t.Fatal("wrong number of stations")
+ }
+ if (*response.Response.InternetRadioStations.List[0].ID != station1IDT) ||
+ (response.Response.InternetRadioStations.List[0].StreamURL != newstation1StreamURL) ||
+ (response.Response.InternetRadioStations.List[0].Name != newstation1Name) ||
+ (response.Response.InternetRadioStations.List[0].HomepageURL != newstation1HomepageURL) ||
+ (*response.Response.InternetRadioStations.List[1].ID != station2IDT) ||
+ (response.Response.InternetRadioStations.List[1].StreamURL != newstation2StreamURL) ||
+ (response.Response.InternetRadioStations.List[1].Name != newstation2Name) ||
+ (response.Response.InternetRadioStations.List[1].HomepageURL != "") {
+ t.Fatal("bad data")
+ }
+
+ // delete station 1 and recheck
+ response = runTestCase(t, contr, contr.ServeDeleteInternetRadioStation,
+ url.Values{"id": {station1ID}}, true)
+ checkSuccess(t, response)
+
+ response = runTestCase(t, contr, contr.ServeGetInternetRadioStations, url.Values{}, false) // no need to be admin
+ checkSuccess(t, response)
+
+ if response.Response.InternetRadioStations == nil {
+ t.Fatal("didn't return stations")
+ }
+ if len(response.Response.InternetRadioStations.List) != 1 {
+ t.Fatal("wrong number of stations")
+ }
+ if (*response.Response.InternetRadioStations.List[0].ID != station2IDT) ||
+ (response.Response.InternetRadioStations.List[0].StreamURL != newstation2StreamURL) ||
+ (response.Response.InternetRadioStations.List[0].Name != newstation2Name) ||
+ (response.Response.InternetRadioStations.List[0].HomepageURL != "") {
+ t.Fatal("bad data")
+ }
+
+ // delete station 2 and check that they're all gone
+ response = runTestCase(t, contr, contr.ServeDeleteInternetRadioStation,
+ url.Values{"id": {station2ID}}, true)
+ checkSuccess(t, response)
+
+ response = runTestCase(t, contr, contr.ServeGetInternetRadioStations, url.Values{}, false) // no need to be admin
+ checkSuccess(t, response)
+
+ if (response.Response.InternetRadioStations == nil) || (len(response.Response.InternetRadioStations.List) != 0) {
+ t.Fatal("didn't return empty stations")
+ }
+}
diff --git a/server/ctrlsubsonic/handlers_podcast.go b/server/ctrlsubsonic/handlers_podcast.go
index 44c45783..2557a6c6 100644
--- a/server/ctrlsubsonic/handlers_podcast.go
+++ b/server/ctrlsubsonic/handlers_podcast.go
@@ -46,7 +46,7 @@ func (c *Controller) ServeGetNewestPodcasts(r *http.Request) *spec.Response {
func (c *Controller) ServeDownloadPodcastEpisode(r *http.Request) *spec.Response {
user := r.Context().Value(CtxUser).(*db.User)
if (!user.IsAdmin) {
- return spec.NewError(10, "user not admin")
+ return spec.NewError(50, "user not admin")
}
params := r.Context().Value(CtxParams).(params.Params)
id, err := params.GetID("id")
@@ -62,7 +62,7 @@ func (c *Controller) ServeDownloadPodcastEpisode(r *http.Request) *spec.Response
func (c *Controller) ServeCreatePodcastChannel(r *http.Request) *spec.Response {
user := r.Context().Value(CtxUser).(*db.User)
if (!user.IsAdmin) {
- return spec.NewError(10, "user not admin")
+ return spec.NewError(50, "user not admin")
}
params := r.Context().Value(CtxParams).(params.Params)
rssURL, _ := params.Get("url")
@@ -80,7 +80,7 @@ func (c *Controller) ServeCreatePodcastChannel(r *http.Request) *spec.Response {
func (c *Controller) ServeRefreshPodcasts(r *http.Request) *spec.Response {
user := r.Context().Value(CtxUser).(*db.User)
if (!user.IsAdmin) {
- return spec.NewError(10, "user not admin")
+ return spec.NewError(50, "user not admin")
}
if err := c.Podcasts.RefreshPodcasts(); err != nil {
return spec.NewError(10, "failed to refresh feeds: %s", err)
@@ -91,7 +91,7 @@ func (c *Controller) ServeRefreshPodcasts(r *http.Request) *spec.Response {
func (c *Controller) ServeDeletePodcastChannel(r *http.Request) *spec.Response {
user := r.Context().Value(CtxUser).(*db.User)
if (!user.IsAdmin) {
- return spec.NewError(10, "user not admin")
+ return spec.NewError(50, "user not admin")
}
params := r.Context().Value(CtxParams).(params.Params)
id, err := params.GetID("id")
@@ -107,7 +107,7 @@ func (c *Controller) ServeDeletePodcastChannel(r *http.Request) *spec.Response {
func (c *Controller) ServeDeletePodcastEpisode(r *http.Request) *spec.Response {
user := r.Context().Value(CtxUser).(*db.User)
if (!user.IsAdmin) {
- return spec.NewError(10, "user not admin")
+ return spec.NewError(50, "user not admin")
}
params := r.Context().Value(CtxParams).(params.Params)
id, err := params.GetID("id")
diff --git a/server/ctrlsubsonic/spec/construct_internet_radio.go b/server/ctrlsubsonic/spec/construct_internet_radio.go
new file mode 100644
index 00000000..fda4ff8b
--- /dev/null
+++ b/server/ctrlsubsonic/spec/construct_internet_radio.go
@@ -0,0 +1,12 @@
+package spec
+
+import "go.senan.xyz/gonic/db"
+
+func NewInternetRadioStation(irs *db.InternetRadioStation) *InternetRadioStation {
+ return &InternetRadioStation{
+ ID: irs.SID(),
+ Name: irs.Name,
+ StreamURL: irs.StreamURL,
+ HomepageURL: irs.HomepageURL,
+ }
+}
diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go
index 8a047377..06c1fb6e 100644
--- a/server/ctrlsubsonic/spec/spec.go
+++ b/server/ctrlsubsonic/spec/spec.go
@@ -13,6 +13,10 @@ const (
xmlns = "http://subsonic.org/restapi"
)
+type SubsonicResponse struct {
+ Response Response `xml:"subsonic-response" json:"subsonic-response"`
+}
+
type Response struct {
Status string `xml:"status,attr" json:"status"`
Version string `xml:"version,attr" json:"version"`
@@ -51,6 +55,7 @@ type Response struct {
TopSongs *TopSongs `xml:"topSongs" json:"topSongs,omitempty"`
SimilarSongs *SimilarSongs `xml:"similarSongs" json:"similarSongs,omitempty"`
SimilarSongsTwo *SimilarSongsTwo `xml:"similarSongs2" json:"similarSongs2,omitempty"`
+ InternetRadioStations *InternetRadioStations `xml:"internetRadioStations" json:"internetRadioStations,omitempty"`
}
func NewResponse() *Response {
@@ -372,3 +377,15 @@ type SimilarSongs struct {
type SimilarSongsTwo struct {
Tracks []*TrackChild `xml:"song,omitempty" json:"song,omitempty"`
}
+
+type InternetRadioStations struct {
+ List []*InternetRadioStation `xml:"internetRadioStation" json:"internetRadioStation,omitempty"`
+}
+
+type InternetRadioStation struct {
+ ID *specid.ID `xml:"id,attr" json:"id"`
+ Name string `xml:"name,attr" json:"name"`
+ StreamURL string `xml:"streamUrl,attr" json:"streamUrl"`
+ HomepageURL string `xml:"homepageUrl,attr" json:"homepageUrl"`
+}
+
diff --git a/server/ctrlsubsonic/specid/ids.go b/server/ctrlsubsonic/specid/ids.go
index ecd2083d..dbddafe9 100644
--- a/server/ctrlsubsonic/specid/ids.go
+++ b/server/ctrlsubsonic/specid/ids.go
@@ -15,17 +15,19 @@ var (
ErrBadSeparator = errors.New("bad separator")
ErrNotAnInt = errors.New("not an int")
ErrBadPrefix = errors.New("bad prefix")
+ ErrBadJSON = errors.New("bad JSON")
)
type IDT string
const (
- Artist IDT = "ar"
- Album IDT = "al"
- Track IDT = "tr"
- Podcast IDT = "pd"
- PodcastEpisode IDT = "pe"
- separator = "-"
+ Artist IDT = "ar"
+ Album IDT = "al"
+ Track IDT = "tr"
+ Podcast IDT = "pd"
+ PodcastEpisode IDT = "pe"
+ InternetRadioStation IDT = "ir"
+ separator = "-"
)
type ID struct {
@@ -55,6 +57,8 @@ func New(in string) (ID, error) {
return ID{Type: Podcast, Value: val}, nil
case PodcastEpisode:
return ID{Type: PodcastEpisode, Value: val}, nil
+ case InternetRadioStation:
+ return ID{Type: InternetRadioStation, Value: val}, nil
default:
return ID{}, fmt.Errorf("%q: %w", partType, ErrBadPrefix)
}
@@ -71,6 +75,17 @@ func (i ID) MarshalJSON() ([]byte, error) {
return json.Marshal(i.String())
}
+func (i *ID) UnmarshalJSON(data []byte) (error) {
+ if (len(data) <= 2) {
+ return fmt.Errorf("too short: %w", ErrBadJSON)
+ }
+ id, err := New(string(data[1:len(data)-1])) // Strip quotes
+ if (err == nil) {
+ *i = id;
+ }
+ return err;
+}
+
func (i ID) MarshalText() ([]byte, error) {
return []byte(i.String()), nil
}
diff --git a/server/server.go b/server/server.go
index fbc6a858..863aafec 100644
--- a/server/server.go
+++ b/server/server.go
@@ -189,6 +189,9 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) {
routAdmin.Handle("/delete_podcast_do", ctrl.H(ctrl.ServePodcastDeleteDo))
routAdmin.Handle("/download_podcast_do", ctrl.H(ctrl.ServePodcastDownloadDo))
routAdmin.Handle("/update_podcast_do", ctrl.H(ctrl.ServePodcastUpdateDo))
+ routAdmin.Handle("/add_internet_radio_station_do", ctrl.H(ctrl.ServeInternetRadioStationAddDo))
+ routAdmin.Handle("/delete_internet_radio_station_do", ctrl.H(ctrl.ServeInternetRadioStationDeleteDo))
+ routAdmin.Handle("/update_internet_radio_station_do", ctrl.H(ctrl.ServeInternetRadioStationUpdateDo))
// middlewares should be run for not found handler
// https://github.com/gorilla/mux/issues/416
@@ -260,6 +263,12 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) {
r.Handle("/deletePodcastChannel{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastChannel))
r.Handle("/deletePodcastEpisode{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastEpisode))
+ // internet radio
+ r.Handle("/getInternetRadioStations{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetInternetRadioStations))
+ r.Handle("/createInternetRadioStation{_:(?:\\.view)?}", ctrl.H(ctrl.ServeCreateInternetRadioStation))
+ r.Handle("/updateInternetRadioStation{_:(?:\\.view)?}", ctrl.H(ctrl.ServeUpdateInternetRadioStation))
+ r.Handle("/deleteInternetRadioStation{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeleteInternetRadioStation))
+
// middlewares should be run for not found handler
// https://github.com/gorilla/mux/issues/416
notFoundHandler := ctrl.H(ctrl.ServeNotFound)