Rename app package to nativeapi

This commit is contained in:
Deluan
2021-06-13 19:15:41 -04:00
parent 03efc48137
commit d54129ecd2
9 changed files with 68 additions and 58 deletions
+125
View File
@@ -0,0 +1,125 @@
package nativeapi
import (
"context"
"net/http"
"net/url"
"strings"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
)
type Router struct {
http.Handler
ds model.DataStore
broker events.Broker
share core.Share
}
func New(ds model.DataStore, broker events.Broker, share core.Share) *Router {
r := &Router{ds: ds, broker: broker, share: share}
r.Handler = r.routes()
return r
}
func (n *Router) routes() http.Handler {
r := chi.NewRouter()
r.Use(server.Authenticator(n.ds))
r.Use(server.JWTRefresher)
n.R(r, "/user", model.User{}, true)
n.R(r, "/song", model.MediaFile{}, true)
n.R(r, "/album", model.Album{}, true)
n.R(r, "/artist", model.Artist{}, true)
n.R(r, "/player", model.Player{}, true)
n.R(r, "/playlist", model.Playlist{}, true)
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
n.RX(r, "/share", n.share.NewRepository, true)
n.RX(r, "/translation", newTranslationRepository, false)
n.addPlaylistTrackRoute(r)
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
})
if conf.Server.DevActivityPanel {
r.Handle("/events", n.broker)
}
return r
}
func (n *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
constructor := func(ctx context.Context) rest.Repository {
return n.ds.Resource(ctx, model)
}
n.RX(r, pathPrefix, constructor, persistable)
}
func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
r.Route(pathPrefix, func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
if persistable {
r.Post("/", rest.Post(constructor))
}
r.Route("/{id}", func(r chi.Router) {
r.Use(urlParams)
r.Get("/", rest.Get(constructor))
if persistable {
r.Put("/", rest.Put(constructor))
r.Delete("/", rest.Delete(constructor))
}
})
})
}
func (n *Router) addPlaylistTrackRoute(r chi.Router) {
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
getPlaylist(n.ds)(w, r)
})
r.Route("/{id}", func(r chi.Router) {
r.Use(urlParams)
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
reorderItem(n.ds)(w, r)
})
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
deleteFromPlaylist(n.ds)(w, r)
})
})
r.With(urlParams).Post("/", func(w http.ResponseWriter, r *http.Request) {
addToPlaylist(n.ds)(w, r)
})
})
}
// Middleware to convert Chi URL params (from Context) to query params, as expected by our REST package
func urlParams(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := chi.RouteContext(r.Context())
parts := make([]string, 0)
for i, key := range ctx.URLParams.Keys {
value := ctx.URLParams.Values[i]
if key == "*" {
continue
}
parts = append(parts, url.QueryEscape(":"+key)+"="+url.QueryEscape(value))
}
q := strings.Join(parts, "&")
if r.URL.RawQuery == "" {
r.URL.RawQuery = q
} else {
r.URL.RawQuery += "&" + q
}
next.ServeHTTP(w, r)
})
}
+17
View File
@@ -0,0 +1,17 @@
package nativeapi
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestNativeApi(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
RunSpecs(t, "Native RESTful API Suite")
}
+171
View File
@@ -0,0 +1,171 @@
package nativeapi
import (
"context"
"encoding/json"
"fmt"
"html"
"net/http"
"strconv"
"strings"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
func getPlaylist(ds model.DataStore) http.HandlerFunc {
// Add a middleware to capture the playlistId
wrapper := func(handler restHandler) http.HandlerFunc {
return func(res http.ResponseWriter, req *http.Request) {
constructor := func(ctx context.Context) rest.Repository {
plsRepo := ds.Playlist(ctx)
plsId := chi.URLParam(req, "playlistId")
return plsRepo.(model.PlaylistRepository).Tracks(plsId)
}
handler(constructor).ServeHTTP(res, req)
}
}
return func(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("accept")
if strings.ToLower(accept) == "audio/x-mpegurl" {
handleExportPlaylist(ds)(w, r)
return
}
wrapper(rest.GetAll)(w, r)
}
}
func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
plsRepo := ds.Playlist(ctx)
plsId := chi.URLParam(r, "playlistId")
pls, err := plsRepo.Get(plsId)
if err == model.ErrNotFound {
log.Warn("Playlist not found", "playlistId", plsId)
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
log.Error("Error retrieving the playlist", "playlistId", plsId, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", pls.Name)
w.Header().Set("Content-Type", "audio/x-mpegurl")
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name)
w.Header().Set("Content-Disposition", disposition)
// TODO: Move this and the import playlist logic to `core`
_, err = w.Write([]byte("#EXTM3U\n"))
if err != nil {
log.Error(ctx, "Error sending playlist", "name", pls.Name)
return
}
for _, t := range pls.Tracks {
header := fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title)
line := t.Path + "\n"
_, err = w.Write([]byte(header + line))
if err != nil {
log.Error(ctx, "Error sending playlist", "name", pls.Name)
return
}
}
}
}
func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
playlistId := utils.ParamString(r, ":playlistId")
id := r.URL.Query().Get(":id")
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId)
err := tracksRepo.Delete(id)
if err == model.ErrNotFound {
log.Warn("Track not found in playlist", "playlistId", playlistId, "id", id)
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
log.Error("Error deleting track from playlist", "playlistId", playlistId, "id", id, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, err = w.Write([]byte("{}"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func addToPlaylist(ds model.DataStore) http.HandlerFunc {
type addTracksPayload struct {
Ids []string `json:"ids"`
}
return func(w http.ResponseWriter, r *http.Request) {
playlistId := utils.ParamString(r, ":playlistId")
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId)
var payload addTracksPayload
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = tracksRepo.Add(payload.Ids)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Must return an object with an ID, to satisfy ReactAdmin `create` call
_, err = fmt.Fprintf(w, `{"id":"%s"}`, html.EscapeString(playlistId))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func reorderItem(ds model.DataStore) http.HandlerFunc {
type reorderPayload struct {
InsertBefore string `json:"insert_before"`
}
return func(w http.ResponseWriter, r *http.Request) {
playlistId := utils.ParamString(r, ":playlistId")
id := utils.ParamInt(r, ":id", 0)
if id == 0 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId)
var payload reorderPayload
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
newPos, err := strconv.Atoi(payload.InsertBefore)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = tracksRepo.Reorder(id, newPos)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_, err = w.Write([]byte(fmt.Sprintf(`{"id":"%d"}`, id)))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
+135
View File
@@ -0,0 +1,135 @@
package nativeapi
import (
"bytes"
"context"
"encoding/json"
"io/ioutil"
"net/http"
"path/filepath"
"strings"
"sync"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/utils"
)
type translation struct {
ID string `json:"id"`
Name string `json:"name"`
Data string `json:"data"`
}
var (
once sync.Once
translations map[string]translation
)
func newTranslationRepository(context.Context) rest.Repository {
dir := utils.NewMergeFS(
http.FS(resources.Assets()),
http.Dir(filepath.Join(conf.Server.DataFolder, "resources")),
)
if err := loadTranslations(dir); err != nil {
log.Error("Error loading translation files", err)
}
return &translationRepository{}
}
type translationRepository struct{}
func (r *translationRepository) Read(id string) (interface{}, error) {
if t, ok := translations[id]; ok {
return t, nil
}
return nil, rest.ErrNotFound
}
// Simple Count implementation. Does not support any `options`
func (r *translationRepository) Count(options ...rest.QueryOptions) (int64, error) {
return int64(len(translations)), nil
}
// Simple ReadAll implementation, only returns IDs. Does not support any `options`
func (r *translationRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
var result []translation
for _, t := range translations {
t.Data = ""
result = append(result, t)
}
return result, nil
}
func (r *translationRepository) EntityName() string {
return "translation"
}
func (r *translationRepository) NewInstance() interface{} {
return &translation{}
}
func loadTranslations(fs http.FileSystem) (loadError error) {
once.Do(func() {
translations = make(map[string]translation)
dir, err := fs.Open(consts.I18nFolder)
if err != nil {
loadError = err
return
}
files, err := dir.Readdir(0)
if err != nil {
loadError = err
return
}
var languages []string
for _, f := range files {
t, err := loadTranslation(fs, f.Name())
if err != nil {
log.Error("Error loading translation file", "file", f.Name(), err)
continue
}
translations[t.ID] = t
languages = append(languages, t.ID)
}
log.Info("Loading translations", "languages", languages)
})
return
}
func loadTranslation(fs http.FileSystem, fileName string) (translation translation, err error) {
// Get id and full path
name := filepath.Base(fileName)
id := strings.TrimSuffix(name, filepath.Ext(name))
filePath := filepath.Join(consts.I18nFolder, name)
// Load translation from json file
file, err := fs.Open(filePath)
if err != nil {
return
}
data, err := ioutil.ReadAll(file)
if err != nil {
return
}
var out map[string]interface{}
if err = json.Unmarshal(data, &out); err != nil {
return
}
// Compress JSON
buf := new(bytes.Buffer)
if err = json.Compact(buf, data); err != nil {
return
}
translation.Data = buf.String()
translation.Name = out["languageName"].(string)
translation.ID = id
return
}
var _ rest.Repository = (*translationRepository)(nil)
+49
View File
@@ -0,0 +1,49 @@
package nativeapi
import (
"encoding/json"
"io/ioutil"
"net/http"
"path/filepath"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/resources"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Translations", func() {
Describe("I18n files", func() {
var fs http.FileSystem
BeforeEach(func() {
fs = http.FS(resources.Assets())
})
It("contains only valid json language files", func() {
dir, _ := fs.Open(consts.I18nFolder)
files, _ := dir.Readdir(0)
for _, f := range files {
name := filepath.Base(f.Name())
filePath := filepath.Join(consts.I18nFolder, name)
file, _ := fs.Open(filePath)
data, _ := ioutil.ReadAll(file)
var out map[string]interface{}
Expect(filepath.Ext(filePath)).To(Equal(".json"), filePath)
Expect(json.Unmarshal(data, &out)).To(BeNil(), filePath)
Expect(out["languageName"]).ToNot(BeEmpty(), filePath)
}
})
})
Describe("loadTranslation", func() {
It("loads a translation file correctly", func() {
fs := http.Dir("ui/src")
tr, err := loadTranslation(fs, "en.json")
Expect(err).To(BeNil())
Expect(tr.ID).To(Equal("en"))
Expect(tr.Name).To(Equal("English"))
var out map[string]interface{}
Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil())
})
})
})