Compare commits

..

13 Commits

Author SHA1 Message Date
Deluan
c4eab5db86 Update dhowden/tag library, to fix extracting images from Ogg files
see https://github.com/dhowden/tag/issues/64
2020-04-11 23:40:35 -04:00
Deluan
4b1c76e307 Keep the order of the playlist when adding new songs. Also allow adding a song more than once 2020-04-11 21:24:15 -04:00
Deluan
e476a5f6f1 Make fields songCount, duration, created and changed mandatory in playlists responses (fixes #164) 2020-04-11 19:15:15 -04:00
Deluan
9fb4f5ef52 Removed Playlist.GetWithTracks, not needed anymore 2020-04-11 19:05:51 -04:00
Deluan
e232c5c561 Add created and changed fields to playlists responses 2020-04-11 18:58:43 -04:00
Deluan
803a5776ae Update link to Subsonic API compatibility doc 2020-04-11 13:19:58 -04:00
Deluan
a6dfcafdab Update themes doc, link to documentation site 2020-04-11 13:13:53 -04:00
Deluan
8f2c7b7913 go mod tidy 2020-04-11 13:10:54 -04:00
jvoisin
2ab647efe1 Add a test 2020-04-11 13:08:21 -04:00
jvoisin
04eb421186 Refactor a bit how ffmpeg is used to get metadata
- createProbeCommand returns a []string instead of (string, string[])
- Simplify the loop of createProbeCommand
2020-04-11 13:08:21 -04:00
Deluan
6a3a66975c Update dhowden/tag library, to fix extracting images from some id3v4 tags
See https://github.com/dhowden/tag/issues/62
2020-04-10 23:42:06 -04:00
jvoisin
1ef4fa970f Simplify a bit ffmpeg's transcoder
- Remove the useless "format" parameter
- createTranscodeCommand now returns a list of string, instead of (string, string[])
2020-04-10 13:00:29 -04:00
jvoisin
b34523e196 Warn if ffmpeg can't be found 2020-04-10 10:56:58 -04:00
24 changed files with 254 additions and 221 deletions

View File

@@ -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|

View File

@@ -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.

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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)

View File

@@ -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))
})
})
})

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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" }))
})
})

View File

@@ -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
}

View File

@@ -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"}]}}

View File

@@ -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>

View File

@@ -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-->
*/
}

View File

@@ -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
})

View File

@@ -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/