mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4eab5db86 | ||
|
|
4b1c76e307 | ||
|
|
e476a5f6f1 | ||
|
|
9fb4f5ef52 | ||
|
|
e232c5c561 | ||
|
|
803a5776ae | ||
|
|
a6dfcafdab | ||
|
|
8f2c7b7913 | ||
|
|
2ab647efe1 | ||
|
|
04eb421186 | ||
|
|
6a3a66975c | ||
|
|
1ef4fa970f | ||
|
|
b34523e196 |
@@ -1,69 +0,0 @@
|
||||
|
||||
### Supported Subsonic API endpoints
|
||||
|
||||
Navidrome is currently compatible with [Subsonic API](http://www.subsonic.org/pages/api.jsp) v1.8.0, with some exceptions.
|
||||
|
||||
This is an (almost) up to date list of all Subsonic API endpoints implemented by Navidrome.
|
||||
Check the "Notes" column for limitations/missing behaviour. Also keep in mind these differences between
|
||||
Navidrome and Subsonic:
|
||||
|
||||
* Right now, Navidrome only works with a single Music Library (Music Folder)
|
||||
* Navidrome does not mark songs as played by calls to `stream`, only when
|
||||
`scrobble` is called with `submission=true`
|
||||
* Next features to be implemented: Last.FM integration, Jukebox, Sharing, Bookmarks, Podcasts, Internet Radio.
|
||||
|
||||
Navidrome is actively being tested with:
|
||||
[DSub](http://www.subsonic.org/pages/apps.jsp#dsub),
|
||||
[Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash) and
|
||||
[Jamstash](http://www.subsonic.org/pages/apps.jsp#jamstash))
|
||||
|
||||
|
||||
| ENDPOINT | NOTES |
|
||||
|------------------------|-------|
|
||||
| _SYSTEM_ ||
|
||||
| `ping` | |
|
||||
| `getLicense` | Always valid ;) |
|
||||
| ||
|
||||
| _BROWSING_ ||
|
||||
| `getMusicFolders` | Hardcoded to just one, set with ND_MUSICFOLDER configuration |
|
||||
| `getIndexes` | Doesn't support shortcuts, nor direct children |
|
||||
| `getMusicDirectory` | |
|
||||
| `getSong` | |
|
||||
| `getArtists` | |
|
||||
| `getArtist` | |
|
||||
| `getAlbum` | |
|
||||
| `getGenres` | |
|
||||
| ||
|
||||
| _ALBUM/SONGS LISTS_ ||
|
||||
| `getAlbumList` | `byYear` and `byGenre` are not implemented |
|
||||
| `getAlbumList2` | `byYear` and `byGenre` are not implemented |
|
||||
| `getStarred` | |
|
||||
| `getStarred2` | |
|
||||
| `getNowPlaying` | |
|
||||
| `getRandomSongs` | Ignores `fromYear` and `toYear` parameters |
|
||||
| ||
|
||||
| _SEARCHING_ ||
|
||||
| `search2` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
| `search3` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
| ||
|
||||
| _PLAYLISTS_ ||
|
||||
| `getPlaylists` | `username` parameter is not implemented |
|
||||
| `getPlaylist` | |
|
||||
| `createPlaylist` | Return empty response on success |
|
||||
| `updatePlaylist` | `comment` and `public` are not implemented. All playlists are public |
|
||||
| `deletePlaylist` | |
|
||||
| ||
|
||||
| _MEDIA RETRIEVAL_ ||
|
||||
| `stream` | |
|
||||
| `download` | |
|
||||
| `getCoverArt` | Only gets embedded artwork |
|
||||
| `getAvatar` | Always returns the same image |
|
||||
| ||
|
||||
| _MEDIA ANNOTATION_ ||
|
||||
| `star` | |
|
||||
| `unstar` | |
|
||||
| `setRating` | |
|
||||
| `scrobble` | No Last.FM support yet. It is used to update play count and last played |
|
||||
| ||
|
||||
| _USER MANAGEMENT_ ||
|
||||
| `getUser` | Hardcoded all roles, ignores `username` parameter|
|
||||
@@ -158,5 +158,5 @@ folder for startup files for your init system.
|
||||
|
||||
## Subsonic API Version Compatibility
|
||||
|
||||
Check the up to date [compatibility table](https://github.com/deluan/navidrome/blob/master/API_COMPATIBILITY.md)
|
||||
Check the up to date [compatibility table](https://www.navidrome.org/docs/developers/subsonic-api)
|
||||
for the latest Subsonic features available.
|
||||
|
||||
@@ -29,7 +29,7 @@ type nd struct {
|
||||
|
||||
TranscodingCacheSize string `default:"100MB"` // in MB
|
||||
ImageCacheSize string `default:"100MB"` // in MB
|
||||
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
|
||||
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogSourceLine bool `default:"false"`
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200411164603, Down20200411164603)
|
||||
}
|
||||
|
||||
func Up20200411164603(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table playlist
|
||||
add created_at datetime;
|
||||
alter table playlist
|
||||
add updated_at datetime;
|
||||
update playlist
|
||||
set created_at = datetime('now'), updated_at = datetime('now');
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200411164603(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
|
||||
log.Error(ctx, "Error loading transcoding command", "format", format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate, format)
|
||||
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
|
||||
@@ -182,7 +182,7 @@ type fakeFFmpeg struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int) (f io.ReadCloser, err error) {
|
||||
ff.r = strings.NewReader(ff.Data)
|
||||
return ff, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
@@ -118,10 +119,12 @@ type PlaylistInfo struct {
|
||||
Public bool
|
||||
Owner string
|
||||
Comment string
|
||||
Created time.Time
|
||||
Changed time.Time
|
||||
}
|
||||
|
||||
func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
pl, err := p.ds.Playlist(ctx).GetWithTracks(id)
|
||||
pl, err := p.ds.Playlist(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -135,6 +138,8 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
Public: pl.Public,
|
||||
Owner: pl.Owner,
|
||||
Comment: pl.Comment,
|
||||
Changed: pl.UpdatedAt,
|
||||
Created: pl.CreatedAt,
|
||||
}
|
||||
|
||||
plsInfo.Entries = FromMediaFiles(pl.Tracks)
|
||||
|
||||
@@ -12,20 +12,25 @@ import (
|
||||
)
|
||||
|
||||
type Transcoder interface {
|
||||
Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
|
||||
Start(ctx context.Context, command, path string, maxBitRate int) (f io.ReadCloser, err error)
|
||||
}
|
||||
|
||||
func New() Transcoder {
|
||||
path, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
log.Error("Unable to find ffmpeg", err)
|
||||
}
|
||||
log.Debug("Found ffmpeg", "path", path)
|
||||
return &ffmpeg{}
|
||||
}
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
arg0, args := createTranscodeCommand(command, path, maxBitRate, format)
|
||||
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int) (f io.ReadCloser, err error) {
|
||||
args := createTranscodeCommand(command, path, maxBitRate)
|
||||
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", arg0, "args", args)
|
||||
cmd := exec.Command(arg0, args...)
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return
|
||||
@@ -38,7 +43,7 @@ func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate in
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (string, []string) {
|
||||
func createTranscodeCommand(cmd, path string, maxBitRate int) []string {
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
@@ -46,5 +51,5 @@ func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (st
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split[0], split[1:]
|
||||
return split
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ func TestTranscoder(t *testing.T) {
|
||||
|
||||
var _ = Describe("createTranscodeCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
cmd, args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, "")
|
||||
Expect(cmd).To(Equal("ffmpeg"))
|
||||
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
|
||||
2
go.mod
2
go.mod
@@ -9,7 +9,7 @@ require (
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/djherbis/fscache v0.10.0
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -26,8 +26,8 @@ github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0 h1:qHX6TTBIsrpg0JkYNko
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU=
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27 h1:Z6xaGRBbqfLR797upHuzQ6w4zg33BLKfAKtVCcmMDgg=
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Playlist struct {
|
||||
ID string
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks MediaFiles
|
||||
ID string
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks MediaFiles
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PlaylistRepository interface {
|
||||
@@ -15,7 +19,6 @@ type PlaylistRepository interface {
|
||||
Exists(id string) (bool, error)
|
||||
Put(pls *Playlist) error
|
||||
Get(id string) (*Playlist, error)
|
||||
GetWithTracks(id string) (*Playlist, error)
|
||||
GetAll(options ...QueryOptions) (Playlists, error)
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
@@ -65,13 +65,12 @@ var (
|
||||
|
||||
var (
|
||||
plsBest = model.Playlist{
|
||||
ID: "10",
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Duration: 10,
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
|
||||
ID: "10",
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
|
||||
}
|
||||
plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "4"}}}
|
||||
testPlaylists = model.Playlists{plsBest, plsCool}
|
||||
|
||||
@@ -3,20 +3,24 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type playlist struct {
|
||||
ID string `orm:"column(id)"`
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks string
|
||||
ID string `orm:"column(id)"`
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type playlistRepository struct {
|
||||
@@ -44,6 +48,10 @@ func (r *playlistRepository) Delete(id string) error {
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
if p.ID == "" {
|
||||
p.CreatedAt = time.Now()
|
||||
}
|
||||
p.UpdatedAt = time.Now()
|
||||
pls := r.fromModel(p)
|
||||
_, err := r.put(pls.ID, pls)
|
||||
return err
|
||||
@@ -57,26 +65,6 @@ func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
|
||||
return &pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
|
||||
pls, err := r.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
|
||||
pls.Duration = 0
|
||||
newTracks := model.MediaFiles{}
|
||||
for _, t := range pls.Tracks {
|
||||
mf, err := mfRepo.Get(t.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pls.Duration += mf.Duration
|
||||
newTracks = append(newTracks, *mf)
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
|
||||
sel := r.newSelect(options...).Columns("*")
|
||||
var res []playlist
|
||||
@@ -94,12 +82,14 @@ func (r *playlistRepository) toModels(all []playlist) model.Playlists {
|
||||
|
||||
func (r *playlistRepository) toModel(p *playlist) model.Playlist {
|
||||
pls := model.Playlist{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Duration: p.Duration,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Duration: p.Duration,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
if strings.TrimSpace(p.Tracks) != "" {
|
||||
tracks := strings.Split(p.Tracks, ",")
|
||||
@@ -107,24 +97,74 @@ func (r *playlistRepository) toModel(p *playlist) model.Playlist {
|
||||
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: t})
|
||||
}
|
||||
}
|
||||
pls.Tracks = r.loadTracks(&pls)
|
||||
return pls
|
||||
}
|
||||
|
||||
func (r *playlistRepository) fromModel(p *model.Playlist) playlist {
|
||||
pls := playlist{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Duration: p.Duration,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
p.Tracks = r.loadTracks(p)
|
||||
var newTracks []string
|
||||
for _, t := range p.Tracks {
|
||||
newTracks = append(newTracks, t.ID)
|
||||
pls.Duration += t.Duration
|
||||
}
|
||||
pls.Tracks = strings.Join(newTracks, ",")
|
||||
return pls
|
||||
}
|
||||
|
||||
// TODO: Introduce a relation table for Playlist <-> MediaFiles, and rewrite this method in pure SQL
|
||||
func (r *playlistRepository) loadTracks(p *model.Playlist) model.MediaFiles {
|
||||
if len(p.Tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect all ids
|
||||
ids := make([]string, len(p.Tracks))
|
||||
for i, t := range p.Tracks {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
|
||||
// Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
const chunkSize = 50
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(ids); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
|
||||
chunks = append(chunks, ids[i:end])
|
||||
}
|
||||
|
||||
// Query each chunk of media_file ids and store results in a map
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
|
||||
trackMap := map[string]model.MediaFile{}
|
||||
for i := range chunks {
|
||||
idsFilter := Eq{"id": chunks[i]}
|
||||
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter})
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Could not load playlist's tracks", "playlistName", p.Name, "playlistId", p.ID, err)
|
||||
}
|
||||
for _, t := range tracks {
|
||||
trackMap[t.ID] = t
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new list of tracks with the same order as the original
|
||||
newTracks := make(model.MediaFiles, len(p.Tracks))
|
||||
for i, t := range p.Tracks {
|
||||
newTracks[i] = trackMap[t.ID]
|
||||
}
|
||||
return newTracks
|
||||
}
|
||||
|
||||
var _ model.PlaylistRepository = (*playlistRepository)(nil)
|
||||
|
||||
@@ -32,34 +32,25 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing playlist", func() {
|
||||
Expect(repo.Get("10")).To(Equal(&plsBest))
|
||||
p, err := repo.Get("10")
|
||||
Expect(err).To(BeNil())
|
||||
// Compare all but Tracks and timestamps
|
||||
p2 := *p
|
||||
p2.Tracks = plsBest.Tracks
|
||||
p2.UpdatedAt = plsBest.UpdatedAt
|
||||
p2.CreatedAt = plsBest.CreatedAt
|
||||
Expect(p2).To(Equal(plsBest))
|
||||
// Compare tracks
|
||||
for i := range p.Tracks {
|
||||
Expect(p.Tracks[i].ID).To(Equal(plsBest.Tracks[i].ID))
|
||||
}
|
||||
})
|
||||
It("returns ErrNotFound for a non-existing playlist", func() {
|
||||
_, err := repo.Get("666")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put/Get/Delete", func() {
|
||||
newPls := model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}}}
|
||||
It("saves the playlist to the DB", func() {
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
})
|
||||
It("returns the newly created playlist", func() {
|
||||
Expect(repo.Get("22")).To(Equal(&newPls))
|
||||
})
|
||||
It("returns deletes the playlist", func() {
|
||||
Expect(repo.Delete("22")).To(BeNil())
|
||||
})
|
||||
It("returns error if tries to retrieve the deleted playlist", func() {
|
||||
_, err := repo.Get("22")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetWithTracks", func() {
|
||||
It("returns an existing playlist", func() {
|
||||
pls, err := repo.GetWithTracks("10")
|
||||
It("returns all tracks", func() {
|
||||
pls, err := repo.Get("10")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Name).To(Equal(plsBest.Name))
|
||||
Expect(pls.Tracks).To(Equal(model.MediaFiles{
|
||||
@@ -69,9 +60,40 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put/Exists/Delete", func() {
|
||||
var newPls model.Playlist
|
||||
BeforeEach(func() {
|
||||
newPls = model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}, {ID: "3"}}}
|
||||
})
|
||||
It("saves the playlist to the DB", func() {
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
})
|
||||
It("adds repeated songs to a playlist and keeps the order", func() {
|
||||
newPls.Tracks = append(newPls.Tracks, model.MediaFile{ID: "4"})
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
saved, _ := repo.Get("22")
|
||||
Expect(saved.Tracks).To(HaveLen(3))
|
||||
Expect(saved.Tracks[0].ID).To(Equal("4"))
|
||||
Expect(saved.Tracks[1].ID).To(Equal("3"))
|
||||
Expect(saved.Tracks[2].ID).To(Equal("4"))
|
||||
})
|
||||
It("returns the newly created playlist", func() {
|
||||
Expect(repo.Exists("22")).To(BeTrue())
|
||||
})
|
||||
It("returns deletes the playlist", func() {
|
||||
Expect(repo.Delete("22")).To(BeNil())
|
||||
})
|
||||
It("returns error if tries to retrieve the deleted playlist", func() {
|
||||
Expect(repo.Exists("22")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("returns all playlists from DB", func() {
|
||||
Expect(repo.GetAll()).To(Equal(model.Playlists{plsBest, plsCool}))
|
||||
all, err := repo.GetAll()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(all[0].ID).To(Equal(plsBest.ID))
|
||||
Expect(all[1].ID).To(Equal(plsCool.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -160,6 +160,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
|
||||
|
||||
func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
values, _ := toSqlArgs(m)
|
||||
// Remove created_at from args and save it for later, if needed fo insert
|
||||
createdAt := values["created_at"]
|
||||
delete(values, "created_at")
|
||||
if id != "" {
|
||||
@@ -178,6 +179,7 @@ func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
id = rand.String()
|
||||
values["id"] = id
|
||||
}
|
||||
// It is a insert, if there was a created_at, add it back to args
|
||||
if createdAt != nil {
|
||||
values["created_at"] = createdAt
|
||||
}
|
||||
|
||||
@@ -74,10 +74,10 @@ func LoadAllAudioFiles(dirPath string) (map[string]os.FileInfo, error) {
|
||||
}
|
||||
|
||||
func ExtractAllMetadata(inputs []string) (map[string]*Metadata, error) {
|
||||
cmdLine, args := createProbeCommand(inputs)
|
||||
args := createProbeCommand(inputs)
|
||||
|
||||
log.Trace("Executing command", "arg0", cmdLine, "args", args)
|
||||
cmd := exec.Command(cmdLine, args...)
|
||||
log.Trace("Executing command", "args", args)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
output, _ := cmd.CombinedOutput()
|
||||
mds := map[string]*Metadata{}
|
||||
if len(output) == 0 {
|
||||
@@ -269,25 +269,18 @@ func (m *Metadata) parseDuration(tagName string) float32 {
|
||||
}
|
||||
|
||||
// Inputs will always be absolute paths
|
||||
func createProbeCommand(inputs []string) (string, []string) {
|
||||
cmd := conf.Server.ProbeCommand
|
||||
|
||||
split := strings.Split(cmd, " ")
|
||||
func createProbeCommand(inputs []string) []string {
|
||||
split := strings.Split(conf.Server.ProbeCommand, " ")
|
||||
args := make([]string, 0)
|
||||
first := true
|
||||
|
||||
for _, s := range split {
|
||||
if s == "%s" {
|
||||
for _, inp := range inputs {
|
||||
if !first {
|
||||
args = append(args, "-i")
|
||||
}
|
||||
args = append(args, inp)
|
||||
first = false
|
||||
args = append(args, "-i", inp)
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
args = append(args, s)
|
||||
}
|
||||
args = append(args, s)
|
||||
}
|
||||
|
||||
return args[0], args[1:]
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -228,4 +228,10 @@ Tracklist:
|
||||
Expect(md.Year()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
It("creates a valid command line", func() {
|
||||
args := createProbeCommand([]string{"/music library/one.mp3", "/music library/two.mp3"})
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata" }))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -36,6 +36,8 @@ func (c *PlaylistsController) GetPlaylists(w http.ResponseWriter, r *http.Reques
|
||||
playlists[i].Duration = int(p.Duration)
|
||||
playlists[i].Owner = p.Owner
|
||||
playlists[i].Public = p.Public
|
||||
playlists[i].Created = p.CreatedAt
|
||||
playlists[i].Changed = p.UpdatedAt
|
||||
}
|
||||
response := NewResponse()
|
||||
response.Playlists = &responses.Playlists{Playlist: playlists}
|
||||
@@ -58,7 +60,7 @@ func (c *PlaylistsController) GetPlaylist(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.Playlist = c.buildPlaylist(r.Context(), pinfo)
|
||||
response.Playlist = c.buildPlaylistWithSongs(r.Context(), pinfo)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -125,15 +127,24 @@ func (c *PlaylistsController) UpdatePlaylist(w http.ResponseWriter, r *http.Requ
|
||||
return NewResponse(), nil
|
||||
}
|
||||
|
||||
func (c *PlaylistsController) buildPlaylist(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs {
|
||||
pls := &responses.PlaylistWithSongs{}
|
||||
func (c *PlaylistsController) buildPlaylistWithSongs(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs {
|
||||
pls := &responses.PlaylistWithSongs{
|
||||
Playlist: *c.buildPlaylist(d),
|
||||
}
|
||||
pls.Entry = ToChildren(ctx, d.Entries)
|
||||
return pls
|
||||
}
|
||||
|
||||
func (c *PlaylistsController) buildPlaylist(d *engine.PlaylistInfo) *responses.Playlist {
|
||||
pls := &responses.Playlist{}
|
||||
pls.Id = d.Id
|
||||
pls.Name = d.Name
|
||||
pls.Comment = d.Comment
|
||||
pls.SongCount = d.SongCount
|
||||
pls.Owner = d.Owner
|
||||
pls.Duration = d.Duration
|
||||
pls.Public = d.Public
|
||||
|
||||
pls.Entry = ToChildren(ctx, d.Entries)
|
||||
pls.Created = d.Created
|
||||
pls.Changed = d.Changed
|
||||
return pls
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","playlists":{"playlist":[{"id":"111","name":"aaa"},{"id":"222","name":"bbb"}]}}
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","playlists":{"playlist":[{"id":"111","name":"aaa","comment":"comment","songCount":2,"duration":120,"public":true,"owner":"admin","created":"0001-01-01T00:00:00Z","changed":"0001-01-01T00:00:00Z"},{"id":"222","name":"bbb","songCount":0,"duration":0,"created":"0001-01-01T00:00:00Z","changed":"0001-01-01T00:00:00Z"}]}}
|
||||
|
||||
@@ -1 +1 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><playlists><playlist id="111" name="aaa"></playlist><playlist id="222" name="bbb"></playlist></playlists></subsonic-response>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><playlists><playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist><playlist id="222" name="bbb" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist></playlists></subsonic-response>
|
||||
|
||||
@@ -188,22 +188,20 @@ type AlbumList struct {
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
|
||||
SongCount int `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
||||
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
|
||||
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
|
||||
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
|
||||
SongCount int `xml:"songCount,attr" json:"songCount"`
|
||||
Duration int `xml:"duration,attr" json:"duration"`
|
||||
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
|
||||
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
|
||||
Created time.Time `xml:"created,attr" json:"created"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
/*
|
||||
<xs:sequence>
|
||||
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
|
||||
</xs:sequence>
|
||||
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
|
||||
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
|
||||
<xs:attribute name="changed" type="xs:dateTime" use="required"/> <!--Added in 1.13.0-->
|
||||
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!--Added in 1.11.0-->
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
|
||||
@@ -235,9 +235,20 @@ var _ = Describe("Responses", func() {
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
timestamp, _ := time.Parse(time.RFC3339, "2020-04-11T16:43:00Z04:00")
|
||||
BeforeEach(func() {
|
||||
pls := make([]Playlist, 2)
|
||||
pls[0] = Playlist{Id: "111", Name: "aaa"}
|
||||
pls[0] = Playlist{
|
||||
Id: "111",
|
||||
Name: "aaa",
|
||||
Comment: "comment",
|
||||
SongCount: 2,
|
||||
Duration: 120,
|
||||
Public: true,
|
||||
Owner: "admin",
|
||||
Created: timestamp,
|
||||
Changed: timestamp,
|
||||
}
|
||||
pls[1] = Playlist{Id: "222", Name: "bbb"}
|
||||
response.Playlists.Playlist = pls
|
||||
})
|
||||
|
||||
@@ -1,20 +1,2 @@
|
||||
## Creating New Themes
|
||||
|
||||
Themes in Navidrome are simple [Material-UI themes](https://material-ui.com/customization/theming/). They are basic JS
|
||||
objects, that allow you to override almost every visual aspect of Navidrome's UI.
|
||||
|
||||
#### Steps to create a new theme:
|
||||
|
||||
1) Create a new JS file in this folder that exports an object containing your theme. Create the theme based on the
|
||||
ReactAdmin/Material UI documentation below. See the existing themes for examples.
|
||||
2) Add a `themeName` property to your theme. This will be displayed in the theme selector
|
||||
3) Add your new theme to the `ui/src/themes/index.js` file
|
||||
4) Start the application, your new theme should now appear as an option in the theme selector
|
||||
|
||||
Before submitting a pull request to include your theme in Navidrome, please test your theme thoroughly and make sure
|
||||
it is formated with the [Prettier](https://prettier.io/) rules found in the project (`ui/src/.prettierrc.js`)
|
||||
|
||||
#### Resources for Material-UI theming
|
||||
|
||||
* Start reading [ReactAdmin documentation](https://marmelab.com/react-admin/Theming.html#writing-a-custom-theme)
|
||||
* Color Tool: https://material-ui.com/customization/color/#official-color-tool
|
||||
To create and contribute with new themes, please refer to
|
||||
https://www.navidrome.org/docs/developers/creating-themes/
|
||||
|
||||
Reference in New Issue
Block a user