Rename app package to nativeapi
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user