From 7ab378accbadf2f25478ae37e231aacca881f7b7 Mon Sep 17 00:00:00 2001 From: brian-doherty <76168809+brian-doherty@users.noreply.github.com> Date: Tue, 21 Jun 2022 16:32:25 -0500 Subject: [PATCH] feat(subsonic): add internet radio support * Initial commit of internet radio support. * Added first test for internet radio. * Refactor to prepare for more test cases. * Added a few more tests. Realized that I was not calling as admin so added ability to mock admin. * Added more internet radio tests. Added proper JSON unmarshaling for ID. * More test cases. Fixed some accidental tabs in files. * Fixed some more tabs. * lint fixes * Changed placeholder for homepage URL to fit into box. * Finished out internet radio test cases. Found a few bad error codes in internet radio AND podcasts (mea culpa). * Realized that delete via website was not checking properly if id existed. Fixed. gofmt --- .gitignore | 1 + db/migrations.go | 7 + db/model.go | 11 + server/assets/pages/home.tmpl | 32 ++ server/ctrladmin/ctrl.go | 1 + server/ctrladmin/handlers.go | 108 ++++- server/ctrlsubsonic/ctrl_test.go | 9 + .../ctrlsubsonic/handlers_internet_radio.go | 131 ++++++ .../handlers_internet_radio_test.go | 380 ++++++++++++++++++ server/ctrlsubsonic/handlers_podcast.go | 10 +- .../spec/construct_internet_radio.go | 12 + server/ctrlsubsonic/spec/spec.go | 17 + server/ctrlsubsonic/specid/ids.go | 27 +- server/server.go | 9 + 14 files changed, 743 insertions(+), 12 deletions(-) create mode 100644 server/ctrlsubsonic/handlers_internet_radio.go create mode 100644 server/ctrlsubsonic/handlers_internet_radio_test.go create mode 100644 server/ctrlsubsonic/spec/construct_internet_radio.go 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 }} +
+
+ internet_radio_stations +
+
+

you can add and update internet radio stations here

+
+
+ + {{ range $pref := .InternetRadioStations }} + + + + + + + + + + {{ end }} + + + + + + + +
+
+
+{{ end }}
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)