mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 13:58:09 -05:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e0acd6a2 | ||
|
|
5f38e70a2b | ||
|
|
c19c599521 | ||
|
|
dd398224e7 | ||
|
|
5ac76ae7e0 | ||
|
|
c14147e6c5 | ||
|
|
59ce940cd6 | ||
|
|
cfecd7c6a2 | ||
|
|
d81a4472a0 | ||
|
|
147d26fb75 | ||
|
|
848318932d | ||
|
|
49153dc1c1 | ||
|
|
ca5da5b0ea | ||
|
|
c2e03c8162 | ||
|
|
f2ebbd26fa | ||
|
|
bbc4f9f91f | ||
|
|
6fe1f84c68 | ||
|
|
d72468003f | ||
|
|
100f6a0645 | ||
|
|
bc2073fbd5 | ||
|
|
278d0ea8f3 | ||
|
|
0e16d7cfbb | ||
|
|
419884db7c | ||
|
|
eacfc41665 | ||
|
|
c271aa24d1 | ||
|
|
22f34b3347 | ||
|
|
eba8395146 | ||
|
|
f16dc5f8f8 | ||
|
|
15c8f4c0ef | ||
|
|
e344f616b3 | ||
|
|
ef81caf3ed | ||
|
|
8513f1a899 | ||
|
|
a9a25713e8 | ||
|
|
a5e1986072 | ||
|
|
97c98e3369 | ||
|
|
6effd603e2 | ||
|
|
8a783ef967 | ||
|
|
b74bd30b72 | ||
|
|
9fa09e41cc | ||
|
|
4ef12f91e0 |
BIN
.github/screenshots/ss-desktop-player.png
vendored
BIN
.github/screenshots/ss-desktop-player.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 5.1 MiB |
BIN
.github/screenshots/ss-mobile-album-view.png
vendored
BIN
.github/screenshots/ss-mobile-album-view.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 283 KiB |
4
.github/workflows/pipeline.yml
vendored
4
.github/workflows/pipeline.yml
vendored
@@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
- name: Run GoReleaser - SNAPSHOT
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
uses: docker://deluan/ci-goreleaser:1.14.4-2
|
||||
uses: docker://deluan/ci-goreleaser:1.14.4-3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker://deluan/ci-goreleaser:1.14.4-2
|
||||
uses: docker://deluan/ci-goreleaser:1.14.4-3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
||||
@@ -34,6 +34,19 @@ builds:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_386
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- 386
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_arm
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
|
||||
2
Makefile
2
Makefile
@@ -103,5 +103,5 @@ release:
|
||||
.PHONY: release
|
||||
|
||||
snapshot:
|
||||
docker run -it -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:1.14.4-2 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
docker run -it -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:1.14.4-3 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: snapshot
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Navidrome Music Streamer
|
||||
# Navidrome Music Server
|
||||
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
@@ -36,7 +36,7 @@ See instructions in the [project's website](https://www.navidrome.org/docs/insta
|
||||
- **Multi-user**, each user has their own play counts, playlists, favourites, etc...
|
||||
- Very **low resource usage**
|
||||
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
|
||||
- Ready to use **Raspberry Pi** binaries and Docker images available
|
||||
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
||||
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
|
||||
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
|
||||
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
|
||||
|
||||
@@ -40,21 +40,17 @@ func CreateAppRouter() *app.Router {
|
||||
|
||||
func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
|
||||
dataStore := persistence.New()
|
||||
browser := engine.NewBrowser(dataStore)
|
||||
artworkCache := core.NewImageCache()
|
||||
artwork := core.NewArtwork(dataStore, artworkCache)
|
||||
nowPlayingRepository := engine.NewNowPlayingRepository()
|
||||
listGenerator := engine.NewListGenerator(dataStore, nowPlayingRepository)
|
||||
users := engine.NewUsers(dataStore)
|
||||
playlists := engine.NewPlaylists(dataStore)
|
||||
scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository)
|
||||
search := engine.NewSearch(dataStore)
|
||||
transcoderTranscoder := transcoder.New()
|
||||
transcodingCache := core.NewTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, transcoderTranscoder, transcodingCache)
|
||||
archiver := core.NewArchiver(dataStore)
|
||||
players := engine.NewPlayers(dataStore)
|
||||
router := subsonic.New(browser, artwork, listGenerator, users, playlists, scrobbler, search, mediaStreamer, archiver, players, dataStore)
|
||||
router := subsonic.New(artwork, listGenerator, playlists, mediaStreamer, archiver, players, dataStore)
|
||||
return router, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ import (
|
||||
)
|
||||
|
||||
type Archiver interface {
|
||||
Zip(ctx context.Context, id string, w io.Writer) error
|
||||
ZipAlbum(ctx context.Context, id string, w io.Writer) error
|
||||
ZipArtist(ctx context.Context, id string, w io.Writer) error
|
||||
}
|
||||
|
||||
func NewArchiver(ds model.DataStore) Archiver {
|
||||
@@ -25,17 +26,33 @@ type archiver struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (a *archiver) Zip(ctx context.Context, id string, out io.Writer) error {
|
||||
mfs, err := a.loadTracks(ctx, id)
|
||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, out io.Writer) error {
|
||||
mfs, err := a.ds.MediaFile(ctx).FindByAlbum(id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading media", "id", id, err)
|
||||
log.Error(ctx, "Error loading mediafiles from album", "id", id, err)
|
||||
return err
|
||||
}
|
||||
return a.zipTracks(ctx, id, out, mfs)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipArtist(ctx context.Context, id string, out io.Writer) error {
|
||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Sort: "album",
|
||||
Filters: squirrel.Eq{"album_artist_id": id},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
return a.zipTracks(ctx, id, out, mfs)
|
||||
}
|
||||
|
||||
func (a *archiver) zipTracks(ctx context.Context, id string, out io.Writer, mfs model.MediaFiles) error {
|
||||
z := zip.NewWriter(out)
|
||||
for _, mf := range mfs {
|
||||
_ = a.addFileToZip(ctx, z, mf)
|
||||
}
|
||||
err = z.Close()
|
||||
err := z.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||
}
|
||||
@@ -66,28 +83,3 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *archiver) loadTracks(ctx context.Context, id string) (model.MediaFiles, error) {
|
||||
exist, err := a.ds.Album(ctx).Exists(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exist {
|
||||
return a.ds.MediaFile(ctx).FindByAlbum(id)
|
||||
}
|
||||
exist, err = a.ds.Artist(ctx).Exists(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exist {
|
||||
return a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Sort: "album",
|
||||
Filters: squirrel.Eq{"album_artist_id": id},
|
||||
})
|
||||
}
|
||||
mf, err := a.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return model.MediaFiles{*mf}, nil
|
||||
}
|
||||
|
||||
@@ -90,17 +90,25 @@ func (c *artwork) getImagePath(ctx context.Context, id string) (path string, las
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Looking for media file art", "id", id)
|
||||
// if id is a mediafile cover id
|
||||
|
||||
// Check if id is a mediaFile cover id
|
||||
var mf *model.MediaFile
|
||||
mf, err = c.ds.MediaFile(ctx).Get(id)
|
||||
|
||||
// If it is not, may be an albumId
|
||||
if err == model.ErrNotFound {
|
||||
return c.getImagePath(ctx, "al-"+id)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// If it is a mediaFile and it has cover art, return it
|
||||
if mf.HasCoverArt {
|
||||
return mf.Path, mf.UpdatedAt, nil
|
||||
}
|
||||
|
||||
// if the mediafile does not have a coverArt, fallback to the album cover
|
||||
// if the mediaFile does not have a coverArt, fallback to the album cover
|
||||
log.Trace(ctx, "Media file does not contain art. Falling back to album art", "id", id, "albumId", "al-"+mf.AlbumID)
|
||||
return c.getImagePath(ctx, "al-"+mf.AlbumID)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,16 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("retrieves the album artwork by album id", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(artwork.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("resized artwork art as requested", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
|
||||
4
go.mod
4
go.mod
@@ -21,7 +21,7 @@ require (
|
||||
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
|
||||
github.com/lib/pq v1.3.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/microcosm-cc/bluemonday v1.0.3
|
||||
github.com/microcosm-cc/bluemonday v1.0.4
|
||||
github.com/mitchellh/mapstructure v1.3.2 // indirect
|
||||
github.com/onsi/ginkgo v1.14.0
|
||||
github.com/onsi/gomega v1.10.1
|
||||
@@ -34,7 +34,7 @@ require (
|
||||
github.com/spf13/cobra v1.0.0
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.7.0
|
||||
github.com/spf13/viper v1.7.1
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344 // indirect
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae // indirect
|
||||
golang.org/x/text v0.3.3 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -231,8 +231,8 @@ github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJK
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/microcosm-cc/bluemonday v1.0.3 h1:EjVH7OqbU219kdm8acbveoclh2zZFqPJTJw6VUlTLAQ=
|
||||
github.com/microcosm-cc/bluemonday v1.0.3/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
@@ -347,8 +347,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
|
||||
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
|
||||
github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM=
|
||||
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
|
||||
github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk=
|
||||
github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
|
||||
github.com/ssdb/gossdb v0.0.0-20180723034631-88f6b59b84ec/go.mod h1:QBvMkMya+gXctz3kmljlUCu/yB3GZ6oee+dUozsezQE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
@@ -41,6 +41,7 @@ func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository
|
||||
"artist_id": artistFilter,
|
||||
"year": yearFilter,
|
||||
"recently_played": recentlyPlayedFilter,
|
||||
"starred": booleanFilter,
|
||||
}
|
||||
|
||||
return r
|
||||
@@ -313,5 +314,22 @@ func (r *albumRepository) NewInstance() interface{} {
|
||||
return &model.Album{}
|
||||
}
|
||||
|
||||
func (r albumRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r albumRepository) Save(entity interface{}) (string, error) {
|
||||
album := entity.(*model.Album)
|
||||
id, err := r.put(album.ID, album)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r albumRepository) Update(entity interface{}, cols ...string) error {
|
||||
album := entity.(*model.Album)
|
||||
_, err := r.put(album.ID, album)
|
||||
return err
|
||||
}
|
||||
|
||||
var _ model.AlbumRepository = (*albumRepository)(nil)
|
||||
var _ model.ResourceRepository = (*albumRepository)(nil)
|
||||
var _ rest.Persistable = (*albumRepository)(nil)
|
||||
|
||||
@@ -30,7 +30,8 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor
|
||||
"name": "order_artist_name",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
"name": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -40,7 +41,7 @@ func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBui
|
||||
}
|
||||
|
||||
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), options...)
|
||||
return r.count(r.newSelectWithAnnotation("artist.id"), options...)
|
||||
}
|
||||
|
||||
func (r *artistRepository) Exists(id string) (bool, error) {
|
||||
@@ -197,6 +198,21 @@ func (r *artistRepository) NewInstance() interface{} {
|
||||
return &model.Artist{}
|
||||
}
|
||||
|
||||
var _ model.ArtistRepository = (*artistRepository)(nil)
|
||||
func (r artistRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r artistRepository) Save(entity interface{}) (string, error) {
|
||||
artist := entity.(*model.Artist)
|
||||
err := r.Put(artist)
|
||||
return artist.ID, err
|
||||
}
|
||||
|
||||
func (r artistRepository) Update(entity interface{}, cols ...string) error {
|
||||
artist := entity.(*model.Artist)
|
||||
return r.Put(artist)
|
||||
}
|
||||
|
||||
var _ model.ArtistRepository = (*artistRepository)(nil)
|
||||
var _ model.ResourceRepository = (*artistRepository)(nil)
|
||||
var _ rest.Persistable = (*artistRepository)(nil)
|
||||
|
||||
@@ -46,6 +46,13 @@
|
||||
"playNext": "Přehrát další",
|
||||
"addToQueue": "Přehrát později",
|
||||
"shuffle": "Zamíchat"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Všechno",
|
||||
"random": "Náhodné",
|
||||
"recentlyAdded": "Nedávno přidané",
|
||||
"recentlyPlayed": "Nedávno přehráté",
|
||||
"mostPlayed": "Nejpřehrávanější"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -98,7 +105,9 @@
|
||||
"updatedAt": "Nahrán",
|
||||
"createdAt": "Vytvořen",
|
||||
"songCount": "Skladby",
|
||||
"comment": "Komentář"
|
||||
"comment": "Komentář",
|
||||
"sync": "Auto-import",
|
||||
"path": "Importovat z"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Přidat skladby do seznamu:",
|
||||
@@ -219,7 +228,7 @@
|
||||
"page_out_from_end": "Nelze jít za poslední stranu",
|
||||
"page_out_from_begin": "Nelze jít před první stranu",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}",
|
||||
"page_rows_per_page": "Řádků na stránce:",
|
||||
"page_rows_per_page": "Položek na stránce:",
|
||||
"next": "Další",
|
||||
"prev": "Předchozí"
|
||||
},
|
||||
@@ -254,9 +263,11 @@
|
||||
"name": "Osobní",
|
||||
"options": {
|
||||
"theme": "Motiv",
|
||||
"language": "Jazyk"
|
||||
"language": "Jazyk",
|
||||
"defaultView": "Výchozí stránka"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Alba"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Fronta",
|
||||
|
||||
@@ -46,6 +46,13 @@
|
||||
"playNext": "Als nächstes abspielen",
|
||||
"addToQueue": "Später abspielen",
|
||||
"shuffle": "Zufallswiedergabe"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alle",
|
||||
"random": "Zufällig",
|
||||
"recentlyAdded": "Kürzlich hinzugefügt",
|
||||
"recentlyPlayed": "Kürzlich gespielt",
|
||||
"mostPlayed": "Meist gespielt"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -98,7 +105,9 @@
|
||||
"updatedAt": "Aktualisiert um",
|
||||
"createdAt": "Erstellt um",
|
||||
"songCount": "Songanzahl",
|
||||
"comment": "Kommentar"
|
||||
"comment": "Kommentar",
|
||||
"sync": "Auto-Import",
|
||||
"path": "Importieren aus"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Songs zur Playlist hinzufügen",
|
||||
@@ -254,9 +263,11 @@
|
||||
"name": "Persönlich",
|
||||
"options": {
|
||||
"theme": "Design",
|
||||
"language": "Sprache"
|
||||
"language": "Sprache",
|
||||
"defaultView": "Standard-Ansicht"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Alben"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Wiedergabeliste abspielen",
|
||||
|
||||
@@ -46,6 +46,13 @@
|
||||
"playNext": "Lire ensuite",
|
||||
"addToQueue": "Ajouter à la file",
|
||||
"shuffle": "Mélanger"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Tous",
|
||||
"random": "Aléatoire",
|
||||
"recentlyAdded": "Récemment ajouté",
|
||||
"recentlyPlayed": "Récemment joué",
|
||||
"mostPlayed": "Plus joués"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -98,7 +105,9 @@
|
||||
"updatedAt": "Mise à jour le",
|
||||
"createdAt": "Crée le",
|
||||
"songCount": "Titres",
|
||||
"comment": "Commentaire"
|
||||
"comment": "Commentaire",
|
||||
"sync": "Import automatique",
|
||||
"path": "Importer depuis"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Ajouter les pistes à la playlist",
|
||||
@@ -242,8 +251,8 @@
|
||||
"transcodingEnabled": "Navidrome fonctionne actuellement avec %{config}, rendant possible l’exécution de commandes arbitraires depuis l'interface web. Il est recommandé de n'activer cette fonctionnalité uniquement lors de la configuration du Transcodage.",
|
||||
"songsAddedToPlaylist": "Une piste ajoutée à la playlist |||| %{smart_count} pistes ajoutées à la playlist",
|
||||
"noPlaylistsAvailable": "Aucune playlist",
|
||||
"delete_user_title": "",
|
||||
"delete_user_content": ""
|
||||
"delete_user_title": "Supprimer l'utilisateur '%{name}'",
|
||||
"delete_user_content": "Êtes-vous sûr de vouloir supprimer cet utilisateur et ses données associées (y compris ses playlists et préférences)"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothèque",
|
||||
@@ -254,9 +263,11 @@
|
||||
"name": "Paramètres personel",
|
||||
"options": {
|
||||
"theme": "Thème",
|
||||
"language": "Langue"
|
||||
"language": "Langue",
|
||||
"defaultView": "Vue par défaut"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albums"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "File de lecture",
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"addToQueue": "Adicionar à fila",
|
||||
"playNow": "Tocar agora",
|
||||
"addToPlaylist": "Adicionar à playlist",
|
||||
"shuffleAll": "Aleatório"
|
||||
"shuffleAll": "Aleatório",
|
||||
"download": "Baixar"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -52,6 +53,7 @@
|
||||
"lists": {
|
||||
"all": "Todos",
|
||||
"random": "Aleatório",
|
||||
"starred": "Favoritos",
|
||||
"recentlyAdded": "Recém-adicionados",
|
||||
"recentlyPlayed": "Recém-tocados",
|
||||
"mostPlayed": "Mais tocados"
|
||||
@@ -62,7 +64,8 @@
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"albumCount": "Total de Álbuns",
|
||||
"songCount": "Total de Músicas"
|
||||
"songCount": "Total de Músicas",
|
||||
"playCount": "Execuções"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Sonra çal",
|
||||
"playNow": "Şimdi cal",
|
||||
"playNow": "Şimdi çal",
|
||||
"addToPlaylist": "Çalma listesine ekle"
|
||||
}
|
||||
},
|
||||
@@ -46,6 +46,13 @@
|
||||
"playNext": "Sonrakini çal",
|
||||
"addToQueue": "Sonra çal",
|
||||
"shuffle": "Karıştır"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Hepsi",
|
||||
"random": "Rasgele",
|
||||
"recentlyAdded": "Son Eklenenler",
|
||||
"recentlyPlayed": "Son oynatılan",
|
||||
"mostPlayed": "En çok çalanlar"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -98,7 +105,9 @@
|
||||
"updatedAt": "Güncelleme tarihi:",
|
||||
"createdAt": "Oluşturma tarihi:",
|
||||
"songCount": "Şarkılar",
|
||||
"comment": "Yorum"
|
||||
"comment": "Yorum",
|
||||
"sync": "otomatik-aktarma",
|
||||
"path": "'dan Aktar"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Bir çalma listesi seç:",
|
||||
@@ -254,9 +263,11 @@
|
||||
"name": "Kişisel",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Dil"
|
||||
"language": "Dil",
|
||||
"defaultView": "Varsayılan görünüm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albümler"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Oynatma Sırası",
|
||||
|
||||
@@ -22,7 +22,7 @@ func NewAlbumListController(listGen engine.ListGenerator) *AlbumListController {
|
||||
}
|
||||
|
||||
func (c *AlbumListController) getNewAlbumList(r *http.Request) (engine.Entries, error) {
|
||||
typ, err := RequiredParamString(r, "type", "Required string parameter 'type' is not present")
|
||||
typ, err := requiredParamString(r, "type", "Required string parameter 'type' is not present")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -69,22 +69,22 @@ func (c *AlbumListController) getNewAlbumList(r *http.Request) (engine.Entries,
|
||||
func (c *AlbumListController) GetAlbumList(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
albums, err := c.getNewAlbumList(r)
|
||||
if err != nil {
|
||||
return nil, NewError(responses.ErrorGeneric, err.Error())
|
||||
return nil, newError(responses.ErrorGeneric, err.Error())
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.AlbumList = &responses.AlbumList{Album: ToChildren(r.Context(), albums)}
|
||||
response := newResponse()
|
||||
response.AlbumList = &responses.AlbumList{Album: toChildren(r.Context(), albums)}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *AlbumListController) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
albums, err := c.getNewAlbumList(r)
|
||||
if err != nil {
|
||||
return nil, NewError(responses.ErrorGeneric, err.Error())
|
||||
return nil, newError(responses.ErrorGeneric, err.Error())
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.AlbumList2 = &responses.AlbumList{Album: ToAlbums(r.Context(), albums)}
|
||||
response := newResponse()
|
||||
response.AlbumList2 = &responses.AlbumList{Album: toAlbums(r.Context(), albums)}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -92,14 +92,14 @@ func (c *AlbumListController) GetStarred(w http.ResponseWriter, r *http.Request)
|
||||
artists, albums, mediaFiles, err := c.listGen.GetAllStarred(r.Context())
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving starred media", "error", err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.Starred = &responses.Starred{}
|
||||
response.Starred.Artist = ToArtists(artists)
|
||||
response.Starred.Album = ToChildren(r.Context(), albums)
|
||||
response.Starred.Song = ToChildren(r.Context(), mediaFiles)
|
||||
response.Starred.Artist = toArtists(artists)
|
||||
response.Starred.Album = toChildren(r.Context(), albums)
|
||||
response.Starred.Song = toChildren(r.Context(), mediaFiles)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -107,14 +107,14 @@ func (c *AlbumListController) GetStarred2(w http.ResponseWriter, r *http.Request
|
||||
artists, albums, mediaFiles, err := c.listGen.GetAllStarred(r.Context())
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving starred media", "error", err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.Starred2 = &responses.Starred{}
|
||||
response.Starred2.Artist = ToArtists(artists)
|
||||
response.Starred2.Album = ToAlbums(r.Context(), albums)
|
||||
response.Starred2.Song = ToChildren(r.Context(), mediaFiles)
|
||||
response.Starred2.Artist = toArtists(artists)
|
||||
response.Starred2.Album = toAlbums(r.Context(), albums)
|
||||
response.Starred2.Song = toChildren(r.Context(), mediaFiles)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -122,14 +122,14 @@ func (c *AlbumListController) GetNowPlaying(w http.ResponseWriter, r *http.Reque
|
||||
npInfos, err := c.listGen.GetNowPlaying(r.Context())
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving now playing list", "error", err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.NowPlaying = &responses.NowPlaying{}
|
||||
response.NowPlaying.Entry = make([]responses.NowPlayingEntry, len(npInfos))
|
||||
for i, entry := range npInfos {
|
||||
response.NowPlaying.Entry[i].Child = ToChild(r.Context(), entry)
|
||||
response.NowPlaying.Entry[i].Child = toChild(r.Context(), entry)
|
||||
response.NowPlaying.Entry[i].UserName = entry.UserName
|
||||
response.NowPlaying.Entry[i].MinutesAgo = entry.MinutesAgo
|
||||
response.NowPlaying.Entry[i].PlayerId = entry.PlayerId
|
||||
@@ -147,12 +147,12 @@ func (c *AlbumListController) GetRandomSongs(w http.ResponseWriter, r *http.Requ
|
||||
songs, err := c.listGen.GetSongs(r.Context(), 0, size, engine.SongsByRandom(genre, fromYear, toYear))
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving random songs", "error", err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.RandomSongs = &responses.Songs{}
|
||||
response.RandomSongs.Songs = ToChildren(r.Context(), songs)
|
||||
response.RandomSongs.Songs = toChildren(r.Context(), songs)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -164,11 +164,11 @@ func (c *AlbumListController) GetSongsByGenre(w http.ResponseWriter, r *http.Req
|
||||
songs, err := c.listGen.GetSongs(r.Context(), offset, count, engine.SongsByGenre(genre))
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving random songs", "error", err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.SongsByGenre = &responses.Songs{}
|
||||
response.SongsByGenre.Songs = ToChildren(r.Context(), songs)
|
||||
response.SongsByGenre.Songs = toChildren(r.Context(), songs)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -23,13 +23,9 @@ const Version = "1.12.0"
|
||||
type Handler = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error)
|
||||
|
||||
type Router struct {
|
||||
Browser engine.Browser
|
||||
Artwork core.Artwork
|
||||
ListGenerator engine.ListGenerator
|
||||
Playlists engine.Playlists
|
||||
Scrobbler engine.Scrobbler
|
||||
Search engine.Search
|
||||
Users engine.Users
|
||||
Streamer core.MediaStreamer
|
||||
Archiver core.Archiver
|
||||
Players engine.Players
|
||||
@@ -38,12 +34,11 @@ type Router struct {
|
||||
mux http.Handler
|
||||
}
|
||||
|
||||
func New(browser engine.Browser, artwork core.Artwork, listGenerator engine.ListGenerator, users engine.Users,
|
||||
playlists engine.Playlists, scrobbler engine.Scrobbler, search engine.Search,
|
||||
streamer core.MediaStreamer, archiver core.Archiver, players engine.Players, ds model.DataStore) *Router {
|
||||
r := &Router{Browser: browser, Artwork: artwork, ListGenerator: listGenerator, Playlists: playlists,
|
||||
Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer, Archiver: archiver,
|
||||
Players: players, DataStore: ds}
|
||||
func New(artwork core.Artwork, listGenerator engine.ListGenerator,
|
||||
playlists engine.Playlists, streamer core.MediaStreamer,
|
||||
archiver core.Archiver, players engine.Players, ds model.DataStore) *Router {
|
||||
r := &Router{Artwork: artwork, ListGenerator: listGenerator, Playlists: playlists,
|
||||
Streamer: streamer, Archiver: archiver, Players: players, DataStore: ds}
|
||||
r.mux = r.routes()
|
||||
return r
|
||||
}
|
||||
@@ -59,7 +54,7 @@ func (api *Router) routes() http.Handler {
|
||||
|
||||
r.Use(postFormToQueryParams)
|
||||
r.Use(checkRequiredParameters)
|
||||
r.Use(authenticate(api.Users))
|
||||
r.Use(authenticate(api.DataStore))
|
||||
// TODO Validate version
|
||||
|
||||
// Subsonic endpoints, grouped by controller
|
||||
@@ -82,7 +77,7 @@ func (api *Router) routes() http.Handler {
|
||||
H(withPlayer, "getSong", c.GetSong)
|
||||
H(withPlayer, "getArtistInfo", c.GetArtistInfo)
|
||||
H(withPlayer, "getArtistInfo2", c.GetArtistInfo2)
|
||||
H(withPlayer, "GetTopSongs", c.GetArtistInfo2)
|
||||
H(withPlayer, "GetTopSongs", c.GetTopSongs)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initAlbumListController(api)
|
||||
@@ -182,7 +177,7 @@ func HGone(r chi.Router, path string) {
|
||||
}
|
||||
|
||||
func SendError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
code := responses.ErrorGeneric
|
||||
if e, ok := err.(SubsonicError); ok {
|
||||
code = e.code
|
||||
|
||||
@@ -24,14 +24,14 @@ func (c *BookmarksController) GetBookmarks(w http.ResponseWriter, r *http.Reques
|
||||
repo := c.ds.MediaFile(r.Context())
|
||||
bmks, err := repo.GetBookmarks()
|
||||
if err != nil {
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.Bookmarks = &responses.Bookmarks{}
|
||||
for _, bmk := range bmks {
|
||||
b := responses.Bookmark{
|
||||
Entry: []responses.Child{ChildFromMediaFile(r.Context(), bmk.Item)},
|
||||
Entry: []responses.Child{childFromMediaFile(r.Context(), bmk.Item)},
|
||||
Position: bmk.Position,
|
||||
Username: user.UserName,
|
||||
Comment: bmk.Comment,
|
||||
@@ -44,7 +44,7 @@ func (c *BookmarksController) GetBookmarks(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
func (c *BookmarksController) CreateBookmark(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id, err := RequiredParamString(r, "id", "id parameter required")
|
||||
id, err := requiredParamString(r, "id", "id parameter required")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -55,13 +55,13 @@ func (c *BookmarksController) CreateBookmark(w http.ResponseWriter, r *http.Requ
|
||||
repo := c.ds.MediaFile(r.Context())
|
||||
err = repo.AddBookmark(id, comment, position)
|
||||
if err != nil {
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
return NewResponse(), nil
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (c *BookmarksController) DeleteBookmark(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id, err := RequiredParamString(r, "id", "id parameter required")
|
||||
id, err := requiredParamString(r, "id", "id parameter required")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -69,9 +69,9 @@ func (c *BookmarksController) DeleteBookmark(w http.ResponseWriter, r *http.Requ
|
||||
repo := c.ds.MediaFile(r.Context())
|
||||
err = repo.DeleteBookmark(id)
|
||||
if err != nil {
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
return NewResponse(), nil
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (c *BookmarksController) GetPlayQueue(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
@@ -80,12 +80,12 @@ func (c *BookmarksController) GetPlayQueue(w http.ResponseWriter, r *http.Reques
|
||||
repo := c.ds.PlayQueue(r.Context())
|
||||
pq, err := repo.Retrieve(user.ID)
|
||||
if err != nil {
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.PlayQueue = &responses.PlayQueue{
|
||||
Entry: ChildrenFromMediaFiles(r.Context(), pq.Items),
|
||||
Entry: childrenFromMediaFiles(r.Context(), pq.Items),
|
||||
Current: pq.Current,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
@@ -96,7 +96,7 @@ func (c *BookmarksController) GetPlayQueue(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
func (c *BookmarksController) SavePlayQueue(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
ids, err := RequiredParamStrings(r, "id", "id parameter required")
|
||||
ids, err := requiredParamStrings(r, "id", "id parameter required")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func (c *BookmarksController) SavePlayQueue(w http.ResponseWriter, r *http.Reque
|
||||
repo := c.ds.PlayQueue(r.Context())
|
||||
err = repo.Store(pq)
|
||||
if err != nil {
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
return NewResponse(), nil
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
@@ -3,41 +3,60 @@ package subsonic
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/server/subsonic/engine"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type BrowsingController struct {
|
||||
browser engine.Browser
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewBrowsingController(browser engine.Browser) *BrowsingController {
|
||||
return &BrowsingController{browser: browser}
|
||||
func NewBrowsingController(ds model.DataStore) *BrowsingController {
|
||||
return &BrowsingController{ds: ds}
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetMusicFolders(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
mediaFolderList, _ := c.browser.MediaFolders(r.Context())
|
||||
mediaFolderList, _ := c.ds.MediaFolder(r.Context()).GetAll()
|
||||
folders := make([]responses.MusicFolder, len(mediaFolderList))
|
||||
for i, f := range mediaFolderList {
|
||||
folders[i].Id = f.ID
|
||||
folders[i].Name = f.Name
|
||||
}
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.MusicFolders = &responses.MusicFolders{Folders: folders}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) getArtistIndex(r *http.Request, musicFolderId string, ifModifiedSince time.Time) (*responses.Indexes, error) {
|
||||
indexes, lastModified, err := c.browser.Indexes(r.Context(), musicFolderId, ifModifiedSince)
|
||||
func (c *BrowsingController) getArtistIndex(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (*responses.Indexes, error) {
|
||||
folder, err := c.ds.MediaFolder(ctx).Get(mediaFolderId)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving Indexes", "error", err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
log.Error(ctx, "Error retrieving MediaFolder", "id", mediaFolderId, err)
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
l, err := c.ds.Property(ctx).DefaultGet(model.PropLastScan+"-"+folder.Path, "-1")
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving LastScan property", err)
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
var indexes model.ArtistIndexes
|
||||
ms, _ := strconv.ParseInt(l, 10, 64)
|
||||
lastModified := utils.ToTime(ms)
|
||||
if lastModified.After(ifModifiedSince) {
|
||||
indexes, err = c.ds.Artist(ctx).GetIndex()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving Indexes", err)
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
}
|
||||
|
||||
res := &responses.Indexes{
|
||||
@@ -62,106 +81,152 @@ func (c *BrowsingController) GetIndexes(w http.ResponseWriter, r *http.Request)
|
||||
musicFolderId := utils.ParamString(r, "musicFolderId")
|
||||
ifModifiedSince := utils.ParamTime(r, "ifModifiedSince", time.Time{})
|
||||
|
||||
res, err := c.getArtistIndex(r, musicFolderId, ifModifiedSince)
|
||||
res, err := c.getArtistIndex(r.Context(), musicFolderId, ifModifiedSince)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.Indexes = res
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetArtists(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
musicFolderId := utils.ParamString(r, "musicFolderId")
|
||||
res, err := c.getArtistIndex(r, musicFolderId, time.Time{})
|
||||
res, err := c.getArtistIndex(r.Context(), musicFolderId, time.Time{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.Artist = res
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetMusicDirectory(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := utils.ParamString(r, "id")
|
||||
dir, err := c.browser.Directory(r.Context(), id)
|
||||
ctx := r.Context()
|
||||
|
||||
entity, err := getEntityByID(ctx, c.ds, id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
log.Error(r, "Requested ID not found ", "id", id)
|
||||
return nil, NewError(responses.ErrorDataNotFound, "Directory not found")
|
||||
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
|
||||
case err != nil:
|
||||
log.Error(err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.Directory = c.buildDirectory(r.Context(), dir)
|
||||
var dir *responses.Directory
|
||||
|
||||
switch v := entity.(type) {
|
||||
case *model.Artist:
|
||||
dir, err = c.buildArtistDirectory(ctx, v)
|
||||
case *model.Album:
|
||||
dir, err = c.buildAlbumDirectory(ctx, v)
|
||||
default:
|
||||
log.Error(r, "Requested ID of invalid type", "id", id, "entity", v)
|
||||
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Directory = dir
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetArtist(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := utils.ParamString(r, "id")
|
||||
dir, err := c.browser.Artist(r.Context(), id)
|
||||
ctx := r.Context()
|
||||
|
||||
artist, err := c.ds.Artist(ctx).Get(id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
log.Error(r, "Requested ArtistID not found ", "id", id)
|
||||
return nil, NewError(responses.ErrorDataNotFound, "Artist not found")
|
||||
log.Error(ctx, "Requested ArtistID not found ", "id", id)
|
||||
return nil, newError(responses.ErrorDataNotFound, "Artist not found")
|
||||
case err != nil:
|
||||
log.Error(r, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
log.Error(ctx, "Error retrieving artist", "id", id, err)
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.ArtistWithAlbumsID3 = c.buildArtist(r.Context(), dir)
|
||||
albums, err := c.ds.Album(ctx).FindByArtist(id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving albums by artist", "id", id, "name", artist.Name, err)
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.ArtistWithAlbumsID3 = c.buildArtist(ctx, artist, albums)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetAlbum(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := utils.ParamString(r, "id")
|
||||
dir, err := c.browser.Album(r.Context(), id)
|
||||
ctx := r.Context()
|
||||
|
||||
album, err := c.ds.Album(ctx).Get(id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
log.Error(r, "Requested ID not found ", "id", id)
|
||||
return nil, NewError(responses.ErrorDataNotFound, "Album not found")
|
||||
log.Error(ctx, "Requested AlbumID not found ", "id", id)
|
||||
return nil, newError(responses.ErrorDataNotFound, "Album not found")
|
||||
case err != nil:
|
||||
log.Error(r, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
log.Error(ctx, "Error retrieving album", "id", id, err)
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.AlbumWithSongsID3 = c.buildAlbum(r.Context(), dir)
|
||||
mfs, err := c.ds.MediaFile(ctx).FindByAlbum(id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving tracks from album", "id", id, "name", album.Name, err)
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.AlbumWithSongsID3 = c.buildAlbum(ctx, album, mfs)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetSong(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := utils.ParamString(r, "id")
|
||||
song, err := c.browser.GetSong(r.Context(), id)
|
||||
ctx := r.Context()
|
||||
|
||||
mf, err := c.ds.MediaFile(ctx).Get(id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
log.Error(r, "Requested ID not found ", "id", id)
|
||||
return nil, NewError(responses.ErrorDataNotFound, "Song not found")
|
||||
log.Error(r, "Requested MediaFileID not found ", "id", id)
|
||||
return nil, newError(responses.ErrorDataNotFound, "Song not found")
|
||||
case err != nil:
|
||||
log.Error(r, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
log.Error(r, "Error retrieving MediaFile", "id", id, err)
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
child := ToChild(r.Context(), *song)
|
||||
response := newResponse()
|
||||
child := childFromMediaFile(ctx, *mf)
|
||||
response.Song = &child
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetGenres(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
genres, err := c.browser.GetGenres(r.Context())
|
||||
ctx := r.Context()
|
||||
genres, err := c.ds.Genre(ctx).GetAll()
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
for i, g := range genres {
|
||||
if strings.TrimSpace(g.Name) == "" {
|
||||
genres[i].Name = "<Empty>"
|
||||
}
|
||||
}
|
||||
sort.Slice(genres, func(i, j int) bool {
|
||||
return genres[i].Name < genres[j].Name
|
||||
})
|
||||
|
||||
response := NewResponse()
|
||||
response.Genres = ToGenres(genres)
|
||||
response := newResponse()
|
||||
response.Genres = toGenres(genres)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -171,7 +236,7 @@ const placeholderArtistImageLargeUrl = "https://lastfm.freetls.fastly.net/i/u/30
|
||||
|
||||
// TODO Integrate with Last.FM
|
||||
func (c *BrowsingController) GetArtistInfo(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.ArtistInfo = &responses.ArtistInfo{}
|
||||
response.ArtistInfo.Biography = "Biography not available"
|
||||
response.ArtistInfo.SmallImageUrl = placeholderArtistImageSmallUrl
|
||||
@@ -182,7 +247,7 @@ func (c *BrowsingController) GetArtistInfo(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// TODO Integrate with Last.FM
|
||||
func (c *BrowsingController) GetArtistInfo2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.ArtistInfo2 = &responses.ArtistInfo2{}
|
||||
response.ArtistInfo2.Biography = "Biography not available"
|
||||
response.ArtistInfo2.SmallImageUrl = placeholderArtistImageSmallUrl
|
||||
@@ -193,61 +258,85 @@ func (c *BrowsingController) GetArtistInfo2(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// TODO Integrate with Last.FM
|
||||
func (c *BrowsingController) GetTopSongs(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.TopSongs = &responses.TopSongs{}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) buildDirectory(ctx context.Context, d *engine.DirectoryInfo) *responses.Directory {
|
||||
dir := &responses.Directory{
|
||||
Id: d.Id,
|
||||
Name: d.Name,
|
||||
Parent: d.Parent,
|
||||
PlayCount: d.PlayCount,
|
||||
AlbumCount: d.AlbumCount,
|
||||
UserRating: d.UserRating,
|
||||
}
|
||||
if !d.Starred.IsZero() {
|
||||
dir.Starred = &d.Starred
|
||||
func (c *BrowsingController) buildArtistDirectory(ctx context.Context, artist *model.Artist) (*responses.Directory, error) {
|
||||
dir := &responses.Directory{}
|
||||
dir.Id = artist.ID
|
||||
dir.Name = artist.Name
|
||||
dir.PlayCount = artist.PlayCount
|
||||
dir.AlbumCount = artist.AlbumCount
|
||||
dir.UserRating = artist.Rating
|
||||
if artist.Starred {
|
||||
dir.Starred = &artist.StarredAt
|
||||
}
|
||||
|
||||
dir.Child = ToChildren(ctx, d.Entries)
|
||||
return dir
|
||||
albums, err := c.ds.Album(ctx).FindByArtist(artist.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir.Child = childrenFromAlbums(ctx, albums)
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) buildArtist(ctx context.Context, d *engine.DirectoryInfo) *responses.ArtistWithAlbumsID3 {
|
||||
func (c *BrowsingController) buildArtist(ctx context.Context, artist *model.Artist, albums model.Albums) *responses.ArtistWithAlbumsID3 {
|
||||
dir := &responses.ArtistWithAlbumsID3{}
|
||||
dir.Id = d.Id
|
||||
dir.Name = d.Name
|
||||
dir.AlbumCount = d.AlbumCount
|
||||
dir.CoverArt = d.CoverArt
|
||||
if !d.Starred.IsZero() {
|
||||
dir.Starred = &d.Starred
|
||||
dir.Id = artist.ID
|
||||
dir.Name = artist.Name
|
||||
dir.AlbumCount = artist.AlbumCount
|
||||
if artist.Starred {
|
||||
dir.Starred = &artist.StarredAt
|
||||
}
|
||||
|
||||
dir.Album = ToAlbums(ctx, d.Entries)
|
||||
dir.Album = childrenFromAlbums(ctx, albums)
|
||||
return dir
|
||||
}
|
||||
|
||||
func (c *BrowsingController) buildAlbum(ctx context.Context, d *engine.DirectoryInfo) *responses.AlbumWithSongsID3 {
|
||||
func (c *BrowsingController) buildAlbumDirectory(ctx context.Context, album *model.Album) (*responses.Directory, error) {
|
||||
dir := &responses.Directory{}
|
||||
dir.Id = album.ID
|
||||
dir.Name = album.Name
|
||||
dir.Parent = album.AlbumArtistID
|
||||
dir.PlayCount = album.PlayCount
|
||||
dir.UserRating = album.Rating
|
||||
dir.SongCount = album.SongCount
|
||||
dir.CoverArt = album.CoverArtId
|
||||
if album.Starred {
|
||||
dir.Starred = &album.StarredAt
|
||||
}
|
||||
|
||||
mfs, err := c.ds.MediaFile(ctx).FindByAlbum(album.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir.Child = childrenFromMediaFiles(ctx, mfs)
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) buildAlbum(ctx context.Context, album *model.Album, mfs model.MediaFiles) *responses.AlbumWithSongsID3 {
|
||||
dir := &responses.AlbumWithSongsID3{}
|
||||
dir.Id = d.Id
|
||||
dir.Name = d.Name
|
||||
dir.Artist = d.Artist
|
||||
dir.ArtistId = d.ArtistId
|
||||
dir.CoverArt = d.CoverArt
|
||||
dir.SongCount = d.SongCount
|
||||
dir.Duration = d.Duration
|
||||
dir.PlayCount = d.PlayCount
|
||||
dir.Year = d.Year
|
||||
dir.Genre = d.Genre
|
||||
if !d.Created.IsZero() {
|
||||
dir.Created = &d.Created
|
||||
dir.Id = album.ID
|
||||
dir.Name = album.Name
|
||||
dir.Artist = album.AlbumArtist
|
||||
dir.ArtistId = album.AlbumArtistID
|
||||
dir.CoverArt = album.CoverArtId
|
||||
dir.SongCount = album.SongCount
|
||||
dir.Duration = int(album.Duration)
|
||||
dir.PlayCount = album.PlayCount
|
||||
dir.Year = album.MaxYear
|
||||
dir.Genre = album.Genre
|
||||
if !album.CreatedAt.IsZero() {
|
||||
dir.Created = &album.CreatedAt
|
||||
}
|
||||
if !d.Starred.IsZero() {
|
||||
dir.Starred = &d.Starred
|
||||
if album.Starred {
|
||||
dir.Starred = &album.StarredAt
|
||||
}
|
||||
|
||||
dir.Song = ToChildren(ctx, d.Entries)
|
||||
dir.Song = childrenFromMediaFiles(ctx, mfs)
|
||||
return dir
|
||||
}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type Browser interface {
|
||||
MediaFolders(ctx context.Context) (model.MediaFolders, error)
|
||||
Indexes(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error)
|
||||
Directory(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
Artist(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
Album(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
GetSong(ctx context.Context, id string) (*Entry, error)
|
||||
GetGenres(ctx context.Context) (model.Genres, error)
|
||||
}
|
||||
|
||||
func NewBrowser(ds model.DataStore) Browser {
|
||||
return &browser{ds}
|
||||
}
|
||||
|
||||
type browser struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (b *browser) MediaFolders(ctx context.Context) (model.MediaFolders, error) {
|
||||
return b.ds.MediaFolder(ctx).GetAll()
|
||||
}
|
||||
|
||||
func (b *browser) Indexes(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error) {
|
||||
// TODO Proper handling of mediaFolderId param
|
||||
folder, _ := b.ds.MediaFolder(ctx).Get(mediaFolderId)
|
||||
|
||||
l, err := b.ds.Property(ctx).DefaultGet(model.PropLastScan+"-"+folder.Path, "-1")
|
||||
ms, _ := strconv.ParseInt(l, 10, 64)
|
||||
lastModified := utils.ToTime(ms)
|
||||
|
||||
if err != nil {
|
||||
return nil, time.Time{}, fmt.Errorf("error retrieving LastScan property: %v", err)
|
||||
}
|
||||
|
||||
if lastModified.After(ifModifiedSince) {
|
||||
indexes, err := b.ds.Artist(ctx).GetIndex()
|
||||
return indexes, lastModified, err
|
||||
}
|
||||
|
||||
return nil, lastModified, nil
|
||||
}
|
||||
|
||||
type DirectoryInfo struct {
|
||||
Id string
|
||||
Name string
|
||||
Entries Entries
|
||||
Parent string
|
||||
Starred time.Time
|
||||
PlayCount int64
|
||||
UserRating int
|
||||
AlbumCount int
|
||||
CoverArt string
|
||||
Artist string
|
||||
ArtistId string
|
||||
SongCount int
|
||||
Duration int
|
||||
Created time.Time
|
||||
Year int
|
||||
Genre string
|
||||
}
|
||||
|
||||
func (b *browser) Artist(ctx context.Context, id string) (*DirectoryInfo, error) {
|
||||
a, albums, err := b.retrieveArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Found Artist", "id", id, "name", a.Name)
|
||||
return b.buildArtistDir(a, albums), nil
|
||||
}
|
||||
|
||||
func (b *browser) Album(ctx context.Context, id string) (*DirectoryInfo, error) {
|
||||
al, tracks, err := b.retrieveAlbum(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Found Album", "id", id, "name", al.Name)
|
||||
return b.buildAlbumDir(al, tracks), nil
|
||||
}
|
||||
|
||||
func (b *browser) Directory(ctx context.Context, id string) (*DirectoryInfo, error) {
|
||||
switch {
|
||||
case b.isArtist(ctx, id):
|
||||
return b.Artist(ctx, id)
|
||||
case b.isAlbum(ctx, id):
|
||||
return b.Album(ctx, id)
|
||||
default:
|
||||
log.Debug(ctx, "Directory not found", "id", id)
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
func (b *browser) GetSong(ctx context.Context, id string) (*Entry, error) {
|
||||
mf, err := b.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry := FromMediaFile(mf)
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (b *browser) GetGenres(ctx context.Context) (model.Genres, error) {
|
||||
genres, err := b.ds.Genre(ctx).GetAll()
|
||||
for i, g := range genres {
|
||||
if strings.TrimSpace(g.Name) == "" {
|
||||
genres[i].Name = "<Empty>"
|
||||
}
|
||||
}
|
||||
sort.Slice(genres, func(i, j int) bool {
|
||||
return genres[i].Name < genres[j].Name
|
||||
})
|
||||
return genres, err
|
||||
}
|
||||
|
||||
func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums) *DirectoryInfo {
|
||||
dir := &DirectoryInfo{
|
||||
Id: a.ID,
|
||||
Name: a.Name,
|
||||
AlbumCount: a.AlbumCount,
|
||||
}
|
||||
|
||||
dir.Entries = make(Entries, len(albums))
|
||||
for i := range albums {
|
||||
al := albums[i]
|
||||
dir.Entries[i] = FromAlbum(&al)
|
||||
dir.PlayCount += al.PlayCount
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *DirectoryInfo {
|
||||
dir := &DirectoryInfo{
|
||||
Id: al.ID,
|
||||
Name: al.Name,
|
||||
Parent: al.AlbumArtistID,
|
||||
Artist: al.AlbumArtist,
|
||||
ArtistId: al.AlbumArtistID,
|
||||
SongCount: al.SongCount,
|
||||
Duration: int(al.Duration),
|
||||
Created: al.CreatedAt,
|
||||
Year: al.MaxYear,
|
||||
Genre: al.Genre,
|
||||
CoverArt: al.CoverArtId,
|
||||
PlayCount: al.PlayCount,
|
||||
UserRating: al.Rating,
|
||||
}
|
||||
|
||||
if al.Starred {
|
||||
dir.Starred = al.StarredAt
|
||||
}
|
||||
|
||||
dir.Entries = FromMediaFiles(tracks)
|
||||
return dir
|
||||
}
|
||||
|
||||
func (b *browser) isArtist(ctx context.Context, id string) bool {
|
||||
found, err := b.ds.Artist(ctx).Exists(id)
|
||||
if err != nil {
|
||||
log.Debug(ctx, "Error searching for Artist", "id", id, err)
|
||||
return false
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func (b *browser) isAlbum(ctx context.Context, id string) bool {
|
||||
found, err := b.ds.Album(ctx).Exists(id)
|
||||
if err != nil {
|
||||
log.Debug(ctx, "Error searching for Album", "id", id, err)
|
||||
return false
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func (b *browser) retrieveArtist(ctx context.Context, id string) (a *model.Artist, as model.Albums, err error) {
|
||||
a, err = b.ds.Artist(ctx).Get(id)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Error reading Artist %s from DB: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
if as, err = b.ds.Album(ctx).FindByArtist(id); err != nil {
|
||||
err = fmt.Errorf("Error reading %s's albums from DB: %v", a.Name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (b *browser) retrieveAlbum(ctx context.Context, id string) (al *model.Album, mfs model.MediaFiles, err error) {
|
||||
al, err = b.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Error reading Album %s from DB: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
if mfs, err = b.ds.MediaFile(ctx).FindByAlbum(id); err != nil {
|
||||
err = fmt.Errorf("Error reading %s's tracks from DB: %v", al.Name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Browser", func() {
|
||||
var repo *mockGenreRepository
|
||||
var b Browser
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = &mockGenreRepository{data: model.Genres{
|
||||
{Name: "Rock", SongCount: 1000, AlbumCount: 100},
|
||||
{Name: "", SongCount: 13, AlbumCount: 13},
|
||||
{Name: "Electronic", SongCount: 4000, AlbumCount: 40},
|
||||
}}
|
||||
var ds = &persistence.MockDataStore{MockedGenre: repo}
|
||||
b = &browser{ds: ds}
|
||||
})
|
||||
|
||||
It("returns sorted data", func() {
|
||||
Expect(b.GetGenres(context.TODO())).To(Equal(model.Genres{
|
||||
{Name: "<Empty>", SongCount: 13, AlbumCount: 13},
|
||||
{Name: "Electronic", SongCount: 4000, AlbumCount: 40},
|
||||
{Name: "Rock", SongCount: 1000, AlbumCount: 100},
|
||||
}))
|
||||
})
|
||||
|
||||
It("bubbles up errors", func() {
|
||||
repo.err = errors.New("generic error")
|
||||
_, err := b.GetGenres(context.TODO())
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
type mockGenreRepository struct {
|
||||
data model.Genres
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *mockGenreRepository) GetAll() (model.Genres, error) {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
return r.data, nil
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
@@ -160,11 +158,3 @@ func FromArtists(ars model.Artists) Entries {
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func userName(ctx context.Context) string {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return "UNKNOWN"
|
||||
} else {
|
||||
return user.UserName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type Scrobbler interface {
|
||||
Register(ctx context.Context, playerId int, trackId string, playDate time.Time) (*model.MediaFile, error)
|
||||
NowPlaying(ctx context.Context, playerId int, playerName, trackId, username string) (*model.MediaFile, error)
|
||||
}
|
||||
|
||||
func NewScrobbler(ds model.DataStore, npr NowPlayingRepository) Scrobbler {
|
||||
return &scrobbler{ds: ds, npRepo: npr}
|
||||
}
|
||||
|
||||
type scrobbler struct {
|
||||
ds model.DataStore
|
||||
npRepo NowPlayingRepository
|
||||
}
|
||||
|
||||
func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string, playTime time.Time) (*model.MediaFile, error) {
|
||||
var mf *model.MediaFile
|
||||
var err error
|
||||
err = s.ds.WithTx(func(tx model.DataStore) error {
|
||||
mf, err = s.ds.MediaFile(ctx).Get(trackId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.MediaFile(ctx).IncPlayCount(trackId, playTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.Album(ctx).IncPlayCount(mf.AlbumID, playTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.Artist(ctx).IncPlayCount(mf.ArtistID, playTime)
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error while scrobbling", "trackId", trackId, err)
|
||||
} else {
|
||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
}
|
||||
|
||||
return mf, err
|
||||
}
|
||||
|
||||
// TODO Validate if NowPlaying still works after all refactorings
|
||||
func (s *scrobbler) NowPlaying(ctx context.Context, playerId int, playerName, trackId, username string) (*model.MediaFile, error) {
|
||||
mf, err := s.ds.MediaFile(ctx).Get(trackId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if mf == nil {
|
||||
return nil, fmt.Errorf(`ID "%s" not found`, trackId)
|
||||
}
|
||||
|
||||
log.Info("Now Playing", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
|
||||
info := &NowPlayingInfo{TrackID: trackId, Username: username, Start: time.Now(), PlayerId: playerId, PlayerName: playerName}
|
||||
return mf, s.npRepo.Enqueue(info)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
type Search interface {
|
||||
SearchArtist(ctx context.Context, q string, offset int, size int) (Entries, error)
|
||||
SearchAlbum(ctx context.Context, q string, offset int, size int) (Entries, error)
|
||||
SearchSong(ctx context.Context, q string, offset int, size int) (Entries, error)
|
||||
}
|
||||
|
||||
type search struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewSearch(ds model.DataStore) Search {
|
||||
s := &search{ds}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *search) SearchArtist(ctx context.Context, q string, offset int, size int) (Entries, error) {
|
||||
q = sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*")))
|
||||
artists, err := s.ds.Artist(ctx).Search(q, offset, size)
|
||||
if len(artists) == 0 || err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
artistIds := make([]string, len(artists))
|
||||
for i, al := range artists {
|
||||
artistIds[i] = al.ID
|
||||
}
|
||||
return FromArtists(artists), nil
|
||||
}
|
||||
|
||||
func (s *search) SearchAlbum(ctx context.Context, q string, offset int, size int) (Entries, error) {
|
||||
q = sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*")))
|
||||
albums, err := s.ds.Album(ctx).Search(q, offset, size)
|
||||
if len(albums) == 0 || err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
albumIds := make([]string, len(albums))
|
||||
for i, al := range albums {
|
||||
albumIds[i] = al.ID
|
||||
}
|
||||
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (s *search) SearchSong(ctx context.Context, q string, offset int, size int) (Entries, error) {
|
||||
q = sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*")))
|
||||
mediaFiles, err := s.ds.MediaFile(ctx).Search(q, offset, size)
|
||||
if len(mediaFiles) == 0 || err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
trackIds := make([]string, len(mediaFiles))
|
||||
for i, mf := range mediaFiles {
|
||||
trackIds[i] = mf.ID
|
||||
}
|
||||
|
||||
return FromMediaFiles(mediaFiles), nil
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/core/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type Users interface {
|
||||
Authenticate(ctx context.Context, username, password, token, salt, jwt string) (*model.User, error)
|
||||
}
|
||||
|
||||
func NewUsers(ds model.DataStore) Users {
|
||||
return &users{ds}
|
||||
}
|
||||
|
||||
type users struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (u *users) Authenticate(ctx context.Context, username, pass, token, salt, jwt string) (*model.User, error) {
|
||||
user, err := u.ds.User(ctx).FindByUsername(username)
|
||||
if err == model.ErrNotFound {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
valid := false
|
||||
|
||||
switch {
|
||||
case jwt != "":
|
||||
claims, err := auth.Validate(jwt)
|
||||
valid = err == nil && claims["sub"] == username
|
||||
case pass != "":
|
||||
if strings.HasPrefix(pass, "enc:") {
|
||||
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||
pass = string(dec)
|
||||
}
|
||||
}
|
||||
valid = pass == user.Password
|
||||
case token != "":
|
||||
t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt)))
|
||||
valid = t == token
|
||||
}
|
||||
|
||||
if !valid {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
// TODO: Find a way to update LastAccessAt without causing too much retention in the DB
|
||||
//go func() {
|
||||
// err := u.ds.User(ctx).UpdateLastAccessAt(user.ID)
|
||||
// if err != nil {
|
||||
// log.Error(ctx, "Could not update user's lastAccessAt", "user", user.UserName)
|
||||
// }
|
||||
//}()
|
||||
return user, nil
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/core/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Users", func() {
|
||||
Describe("Authenticate", func() {
|
||||
var users Users
|
||||
BeforeEach(func() {
|
||||
ds := &persistence.MockDataStore{}
|
||||
users = NewUsers(ds)
|
||||
})
|
||||
|
||||
Context("Plaintext password", func() {
|
||||
It("authenticates with plaintext password ", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "wordpass", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails authentication with wrong password", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "INVALID", "", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Encoded password", func() {
|
||||
It("authenticates with simple encoded password ", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "enc:776f726470617373", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Token based authentication", func() {
|
||||
It("authenticates with token based authentication", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails if salt is missing", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("JWT based authentication", func() {
|
||||
var validToken string
|
||||
BeforeEach(func() {
|
||||
u := &model.User{UserName: "admin"}
|
||||
var err error
|
||||
validToken, err = auth.CreateToken(u)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
It("authenticates with JWT token based authentication", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "", "", "", validToken)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails if JWT token is invalid", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "", "", "", "invalid.token")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("fails if JWT token sub is different than username", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "not_admin", "", "", "", validToken)
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,12 +5,8 @@ import (
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewBrowser,
|
||||
NewListGenerator,
|
||||
NewPlaylists,
|
||||
NewScrobbler,
|
||||
NewSearch,
|
||||
NewNowPlayingRepository,
|
||||
NewUsers,
|
||||
NewPlayers,
|
||||
)
|
||||
|
||||
@@ -14,30 +14,30 @@ import (
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
func NewResponse() *responses.Subsonic {
|
||||
func newResponse() *responses.Subsonic {
|
||||
return &responses.Subsonic{Status: "ok", Version: Version, Type: consts.AppName, ServerVersion: consts.Version()}
|
||||
}
|
||||
|
||||
func RequiredParamString(r *http.Request, param string, msg string) (string, error) {
|
||||
func requiredParamString(r *http.Request, param string, msg string) (string, error) {
|
||||
p := utils.ParamString(r, param)
|
||||
if p == "" {
|
||||
return "", NewError(responses.ErrorMissingParameter, msg)
|
||||
return "", newError(responses.ErrorMissingParameter, msg)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func RequiredParamStrings(r *http.Request, param string, msg string) ([]string, error) {
|
||||
func requiredParamStrings(r *http.Request, param string, msg string) ([]string, error) {
|
||||
ps := utils.ParamStrings(r, param)
|
||||
if len(ps) == 0 {
|
||||
return nil, NewError(responses.ErrorMissingParameter, msg)
|
||||
return nil, newError(responses.ErrorMissingParameter, msg)
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func RequiredParamInt(r *http.Request, param string, msg string) (int, error) {
|
||||
func requiredParamInt(r *http.Request, param string, msg string) (int, error) {
|
||||
p := utils.ParamString(r, param)
|
||||
if p == "" {
|
||||
return 0, NewError(responses.ErrorMissingParameter, msg)
|
||||
return 0, newError(responses.ErrorMissingParameter, msg)
|
||||
}
|
||||
return utils.ParamInt(r, param, 0), nil
|
||||
}
|
||||
@@ -47,7 +47,7 @@ type SubsonicError struct {
|
||||
messages []interface{}
|
||||
}
|
||||
|
||||
func NewError(code int, message ...interface{}) error {
|
||||
func newError(code int, message ...interface{}) error {
|
||||
return SubsonicError{
|
||||
code: code,
|
||||
messages: message,
|
||||
@@ -64,16 +64,16 @@ func (e SubsonicError) Error() string {
|
||||
return msg
|
||||
}
|
||||
|
||||
func ToAlbums(ctx context.Context, entries engine.Entries) []responses.Child {
|
||||
func toAlbums(ctx context.Context, entries engine.Entries) []responses.Child {
|
||||
children := make([]responses.Child, len(entries))
|
||||
for i, entry := range entries {
|
||||
children[i] = ToAlbum(ctx, entry)
|
||||
children[i] = toAlbum(ctx, entry)
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
func ToAlbum(ctx context.Context, entry engine.Entry) responses.Child {
|
||||
album := ToChild(ctx, entry)
|
||||
func toAlbum(ctx context.Context, entry engine.Entry) responses.Child {
|
||||
album := toChild(ctx, entry)
|
||||
album.Name = album.Title
|
||||
album.Title = ""
|
||||
album.Parent = ""
|
||||
@@ -82,7 +82,7 @@ func ToAlbum(ctx context.Context, entry engine.Entry) responses.Child {
|
||||
return album
|
||||
}
|
||||
|
||||
func ToArtists(entries engine.Entries) []responses.Artist {
|
||||
func toArtists(entries engine.Entries) []responses.Artist {
|
||||
artists := make([]responses.Artist, len(entries))
|
||||
for i, entry := range entries {
|
||||
artists[i] = responses.Artist{
|
||||
@@ -97,15 +97,15 @@ func ToArtists(entries engine.Entries) []responses.Artist {
|
||||
return artists
|
||||
}
|
||||
|
||||
func ToChildren(ctx context.Context, entries engine.Entries) []responses.Child {
|
||||
func toChildren(ctx context.Context, entries engine.Entries) []responses.Child {
|
||||
children := make([]responses.Child, len(entries))
|
||||
for i, entry := range entries {
|
||||
children[i] = ToChild(ctx, entry)
|
||||
children[i] = toChild(ctx, entry)
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
func ToChild(ctx context.Context, entry engine.Entry) responses.Child {
|
||||
func toChild(ctx context.Context, entry engine.Entry) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = entry.Id
|
||||
child.Title = entry.Title
|
||||
@@ -146,7 +146,7 @@ func ToChild(ctx context.Context, entry engine.Entry) responses.Child {
|
||||
return child
|
||||
}
|
||||
|
||||
func ToGenres(genres model.Genres) *responses.Genres {
|
||||
func toGenres(genres model.Genres) *responses.Genres {
|
||||
response := make([]responses.Genre, len(genres))
|
||||
for i, g := range genres {
|
||||
response[i] = responses.Genre(g)
|
||||
@@ -166,7 +166,7 @@ func getTranscoding(ctx context.Context) (format string, bitRate int) {
|
||||
|
||||
// This seems to be duplicated, but it is an initial step into merging `engine` and the `subsonic` packages,
|
||||
// In the future there won't be any conversion to/from `engine. Entry` anymore
|
||||
func ChildFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
|
||||
func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = mf.ID
|
||||
child.Title = mf.Title
|
||||
@@ -187,7 +187,7 @@ func ChildFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
||||
child.CoverArt = "al-" + mf.AlbumID
|
||||
}
|
||||
child.ContentType = mf.ContentType()
|
||||
child.Path = mf.Path
|
||||
child.Path = fmt.Sprintf("%s/%s/%s.%s", realArtistName(mf), mf.Album, mf.Title, mf.Suffix)
|
||||
child.DiscNumber = mf.DiscNumber
|
||||
child.Created = &mf.CreatedAt
|
||||
child.AlbumId = mf.AlbumID
|
||||
@@ -208,10 +208,70 @@ func ChildFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
||||
return child
|
||||
}
|
||||
|
||||
func ChildrenFromMediaFiles(ctx context.Context, mfs model.MediaFiles) []responses.Child {
|
||||
func realArtistName(mf model.MediaFile) string {
|
||||
switch {
|
||||
case mf.Compilation:
|
||||
return consts.VariousArtists
|
||||
case mf.AlbumArtist != "":
|
||||
return mf.AlbumArtist
|
||||
}
|
||||
|
||||
return mf.Artist
|
||||
}
|
||||
|
||||
func childrenFromMediaFiles(ctx context.Context, mfs model.MediaFiles) []responses.Child {
|
||||
children := make([]responses.Child, len(mfs))
|
||||
for i, mf := range mfs {
|
||||
children[i] = ChildFromMediaFile(ctx, mf)
|
||||
children[i] = childFromMediaFile(ctx, mf)
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = al.ID
|
||||
child.IsDir = true
|
||||
child.Title = al.Name
|
||||
child.Name = al.Name
|
||||
child.Album = al.Name
|
||||
child.Artist = al.AlbumArtist
|
||||
child.Year = al.MaxYear
|
||||
child.Genre = al.Genre
|
||||
child.CoverArt = al.CoverArtId
|
||||
child.Created = &al.CreatedAt
|
||||
child.Parent = al.AlbumArtistID
|
||||
child.ArtistId = al.AlbumArtistID
|
||||
child.Duration = int(al.Duration)
|
||||
child.SongCount = al.SongCount
|
||||
if al.Starred {
|
||||
child.Starred = &al.StarredAt
|
||||
}
|
||||
child.PlayCount = al.PlayCount
|
||||
child.UserRating = al.Rating
|
||||
return child
|
||||
}
|
||||
|
||||
func childrenFromAlbums(ctx context.Context, als model.Albums) []responses.Child {
|
||||
children := make([]responses.Child, len(als))
|
||||
for i, al := range als {
|
||||
children[i] = childFromAlbum(ctx, al)
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
// TODO: Should the type be encoded in the ID?
|
||||
func getEntityByID(ctx context.Context, ds model.DataStore, id string) (interface{}, error) {
|
||||
ar, err := ds.Artist(ctx).Get(id)
|
||||
if err == nil {
|
||||
return ar, nil
|
||||
}
|
||||
al, err := ds.Album(ctx).Get(id)
|
||||
if err == nil {
|
||||
return al, nil
|
||||
}
|
||||
mf, err := ds.MediaFile(ctx).Get(id)
|
||||
if err == nil {
|
||||
return mf, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,31 +2,33 @@ package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/deluan/navidrome/server/subsonic/engine"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type MediaAnnotationController struct {
|
||||
scrobbler engine.Scrobbler
|
||||
ds model.DataStore
|
||||
ds model.DataStore
|
||||
npRepo engine.NowPlayingRepository
|
||||
}
|
||||
|
||||
func NewMediaAnnotationController(scrobbler engine.Scrobbler, ds model.DataStore) *MediaAnnotationController {
|
||||
return &MediaAnnotationController{scrobbler: scrobbler, ds: ds}
|
||||
func NewMediaAnnotationController(ds model.DataStore, npr engine.NowPlayingRepository) *MediaAnnotationController {
|
||||
return &MediaAnnotationController{ds: ds, npRepo: npr}
|
||||
}
|
||||
|
||||
func (c *MediaAnnotationController) SetRating(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id, err := RequiredParamString(r, "id", "Required id parameter is missing")
|
||||
id, err := requiredParamString(r, "id", "Required id parameter is missing")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rating, err := RequiredParamInt(r, "rating", "Required rating parameter is missing")
|
||||
rating, err := requiredParamInt(r, "rating", "Required rating parameter is missing")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -37,13 +39,13 @@ func (c *MediaAnnotationController) SetRating(w http.ResponseWriter, r *http.Req
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
log.Error(r, err)
|
||||
return nil, NewError(responses.ErrorDataNotFound, "ID not found")
|
||||
return nil, newError(responses.ErrorDataNotFound, "ID not found")
|
||||
case err != nil:
|
||||
log.Error(r, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
return NewResponse(), nil
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (c *MediaAnnotationController) setRating(ctx context.Context, id string, rating int) error {
|
||||
@@ -62,7 +64,7 @@ func (c *MediaAnnotationController) Star(w http.ResponseWriter, r *http.Request)
|
||||
albumIds := utils.ParamStrings(r, "albumId")
|
||||
artistIds := utils.ParamStrings(r, "artistId")
|
||||
if len(ids)+len(albumIds)+len(artistIds) == 0 {
|
||||
return nil, NewError(responses.ErrorMissingParameter, "Required id parameter is missing")
|
||||
return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing")
|
||||
}
|
||||
ids = append(ids, albumIds...)
|
||||
ids = append(ids, artistIds...)
|
||||
@@ -72,7 +74,7 @@ func (c *MediaAnnotationController) Star(w http.ResponseWriter, r *http.Request)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewResponse(), nil
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (c *MediaAnnotationController) Unstar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
@@ -80,7 +82,7 @@ func (c *MediaAnnotationController) Unstar(w http.ResponseWriter, r *http.Reques
|
||||
albumIds := utils.ParamStrings(r, "albumId")
|
||||
artistIds := utils.ParamStrings(r, "artistId")
|
||||
if len(ids)+len(albumIds)+len(artistIds) == 0 {
|
||||
return nil, NewError(responses.ErrorMissingParameter, "Required id parameter is missing")
|
||||
return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing")
|
||||
}
|
||||
ids = append(ids, albumIds...)
|
||||
ids = append(ids, artistIds...)
|
||||
@@ -90,17 +92,17 @@ func (c *MediaAnnotationController) Unstar(w http.ResponseWriter, r *http.Reques
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewResponse(), nil
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (c *MediaAnnotationController) Scrobble(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
ids, err := RequiredParamStrings(r, "id", "Required id parameter is missing")
|
||||
ids, err := requiredParamStrings(r, "id", "Required id parameter is missing")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
times := utils.ParamTimes(r, "time")
|
||||
if len(times) > 0 && len(times) != len(ids) {
|
||||
return nil, NewError(responses.ErrorGeneric, "Wrong number of timestamps: %d, should be %d", len(times), len(ids))
|
||||
return nil, newError(responses.ErrorGeneric, "Wrong number of timestamps: %d, should be %d", len(times), len(ids))
|
||||
}
|
||||
submission := utils.ParamBool(r, "submission", true)
|
||||
playerId := 1 // TODO Multiple players, based on playerName/username/clientIP(?)
|
||||
@@ -116,20 +118,66 @@ func (c *MediaAnnotationController) Scrobble(w http.ResponseWriter, r *http.Requ
|
||||
t = time.Now()
|
||||
}
|
||||
if submission {
|
||||
_, err := c.scrobbler.Register(r.Context(), playerId, id, t)
|
||||
_, err := c.scrobblerRegister(r.Context(), playerId, id, t)
|
||||
if err != nil {
|
||||
log.Error(r, "Error scrobbling track", "id", id, err)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
_, err := c.scrobbler.NowPlaying(r.Context(), playerId, playerName, id, username)
|
||||
_, err := c.scrobblerNowPlaying(r.Context(), playerId, playerName, id, username)
|
||||
if err != nil {
|
||||
log.Error(r, "Error setting current song", "id", id, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return NewResponse(), nil
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (c *MediaAnnotationController) scrobblerRegister(ctx context.Context, playerId int, trackId string, playTime time.Time) (*model.MediaFile, error) {
|
||||
var mf *model.MediaFile
|
||||
var err error
|
||||
err = c.ds.WithTx(func(tx model.DataStore) error {
|
||||
mf, err = c.ds.MediaFile(ctx).Get(trackId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = c.ds.MediaFile(ctx).IncPlayCount(trackId, playTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = c.ds.Album(ctx).IncPlayCount(mf.AlbumID, playTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = c.ds.Artist(ctx).IncPlayCount(mf.ArtistID, playTime)
|
||||
return err
|
||||
})
|
||||
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if err != nil {
|
||||
log.Error("Error while scrobbling", "trackId", trackId, "user", username, err)
|
||||
} else {
|
||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username)
|
||||
}
|
||||
|
||||
return mf, err
|
||||
}
|
||||
|
||||
func (c *MediaAnnotationController) scrobblerNowPlaying(ctx context.Context, playerId int, playerName, trackId, username string) (*model.MediaFile, error) {
|
||||
mf, err := c.ds.MediaFile(ctx).Get(trackId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if mf == nil {
|
||||
return nil, fmt.Errorf(`ID "%s" not found`, trackId)
|
||||
}
|
||||
|
||||
log.Info("Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username)
|
||||
|
||||
info := &engine.NowPlayingInfo{TrackID: trackId, Username: username, Start: time.Now(), PlayerId: playerId, PlayerName: playerName}
|
||||
return mf, c.npRepo.Enqueue(info)
|
||||
}
|
||||
|
||||
func (c *MediaAnnotationController) setStar(ctx context.Context, star bool, ids ...string) error {
|
||||
@@ -177,10 +225,10 @@ func (c *MediaAnnotationController) setStar(ctx context.Context, star bool, ids
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
log.Error(ctx, err)
|
||||
return NewError(responses.ErrorDataNotFound, "ID not found")
|
||||
return newError(responses.ErrorDataNotFound, "ID not found")
|
||||
case err != nil:
|
||||
log.Error(ctx, err)
|
||||
return NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func (c *MediaRetrievalController) GetAvatar(w http.ResponseWriter, r *http.Requ
|
||||
f, err := resources.AssetFile().Open(consts.PlaceholderAlbumArt)
|
||||
if err != nil {
|
||||
log.Error(r, "Image not found", err)
|
||||
return nil, NewError(responses.ErrorDataNotFound, "Avatar image not found")
|
||||
return nil, newError(responses.ErrorDataNotFound, "Avatar image not found")
|
||||
}
|
||||
defer f.Close()
|
||||
_, _ = io.Copy(w, f)
|
||||
@@ -34,7 +34,7 @@ func (c *MediaRetrievalController) GetAvatar(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (c *MediaRetrievalController) GetCoverArt(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id, err := RequiredParamString(r, "id", "id parameter required")
|
||||
id, err := requiredParamString(r, "id", "id parameter required")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -46,10 +46,10 @@ func (c *MediaRetrievalController) GetCoverArt(w http.ResponseWriter, r *http.Re
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
log.Error(r, "Couldn't find coverArt", "id", id, err)
|
||||
return nil, NewError(responses.ErrorDataNotFound, "Artwork not found")
|
||||
return nil, newError(responses.ErrorDataNotFound, "Artwork not found")
|
||||
case err != nil:
|
||||
log.Error(r, "Error retrieving coverArt", "id", id, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/core/auth"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
@@ -23,7 +27,7 @@ func postFormToQueryParams(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
SendError(w, r, NewError(responses.ErrorGeneric, err.Error()))
|
||||
SendError(w, r, newError(responses.ErrorGeneric, err.Error()))
|
||||
}
|
||||
var parts []string
|
||||
for key, values := range r.Form {
|
||||
@@ -45,7 +49,7 @@ func checkRequiredParameters(next http.Handler) http.Handler {
|
||||
if utils.ParamString(r, p) == "" {
|
||||
msg := fmt.Sprintf(`Missing required parameter "%s"`, p)
|
||||
log.Warn(r, msg)
|
||||
SendError(w, r, NewError(responses.ErrorMissingParameter, msg))
|
||||
SendError(w, r, newError(responses.ErrorMissingParameter, msg))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -64,29 +68,37 @@ func checkRequiredParameters(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func authenticate(users engine.Users) func(next http.Handler) http.Handler {
|
||||
func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
username := utils.ParamString(r, "u")
|
||||
|
||||
pass := utils.ParamString(r, "p")
|
||||
token := utils.ParamString(r, "t")
|
||||
salt := utils.ParamString(r, "s")
|
||||
jwt := utils.ParamString(r, "jwt")
|
||||
|
||||
usr, err := users.Authenticate(r.Context(), username, pass, token, salt, jwt)
|
||||
usr, err := validateUser(ctx, ds, username, pass, token, salt, jwt)
|
||||
if err == model.ErrInvalidAuth {
|
||||
log.Warn(r, "Invalid login", "username", username, err)
|
||||
log.Warn(ctx, "Invalid login", "username", username, err)
|
||||
} else if err != nil {
|
||||
log.Error(r, "Error authenticating username", "username", username, err)
|
||||
log.Error(ctx, "Error authenticating username", "username", username, err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warn(r, "Invalid login", "username", username)
|
||||
SendError(w, r, NewError(responses.ErrorAuthenticationFail))
|
||||
SendError(w, r, newError(responses.ErrorAuthenticationFail))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
// TODO: Find a way to update LastAccessAt without causing too much retention in the DB
|
||||
//go func() {
|
||||
// err := ds.User(ctx).UpdateLastAccessAt(usr.ID)
|
||||
// if err != nil {
|
||||
// log.Error(ctx, "Could not update user's lastAccessAt", "user", usr.UserName)
|
||||
// }
|
||||
//}()
|
||||
|
||||
ctx = request.WithUser(ctx, *usr)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
@@ -95,6 +107,38 @@ func authenticate(users engine.Users) func(next http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func validateUser(ctx context.Context, ds model.DataStore, username, pass, token, salt, jwt string) (*model.User, error) {
|
||||
user, err := ds.User(ctx).FindByUsername(username)
|
||||
if err == model.ErrNotFound {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
valid := false
|
||||
|
||||
switch {
|
||||
case jwt != "":
|
||||
claims, err := auth.Validate(jwt)
|
||||
valid = err == nil && claims["sub"] == username
|
||||
case pass != "":
|
||||
if strings.HasPrefix(pass, "enc:") {
|
||||
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||
pass = string(dec)
|
||||
}
|
||||
}
|
||||
valid = pass == user.Password
|
||||
case token != "":
|
||||
t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt)))
|
||||
valid = t == token
|
||||
}
|
||||
|
||||
if !valid {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func getPlayer(players engine.Players) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/core/auth"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
"github.com/deluan/navidrome/server/subsonic/engine"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -113,29 +115,24 @@ var _ = Describe("Middlewares", func() {
|
||||
})
|
||||
|
||||
Describe("Authenticate", func() {
|
||||
var mockedUsers *mockUsers
|
||||
var ds model.DataStore
|
||||
BeforeEach(func() {
|
||||
mockedUsers = &mockUsers{}
|
||||
ds = &persistence.MockDataStore{}
|
||||
})
|
||||
|
||||
It("passes all parameters to users.Authenticate ", func() {
|
||||
r := newGetRequest("u=valid", "p=password", "t=token", "s=salt", "jwt=jwt")
|
||||
cp := authenticate(mockedUsers)(next)
|
||||
It("passes authentication with correct credentials", func() {
|
||||
r := newGetRequest("u=admin", "p=wordpass")
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(mockedUsers.username).To(Equal("valid"))
|
||||
Expect(mockedUsers.password).To(Equal("password"))
|
||||
Expect(mockedUsers.token).To(Equal("token"))
|
||||
Expect(mockedUsers.salt).To(Equal("salt"))
|
||||
Expect(mockedUsers.jwt).To(Equal("jwt"))
|
||||
Expect(next.called).To(BeTrue())
|
||||
user, _ := request.UserFrom(next.req.Context())
|
||||
Expect(user.UserName).To(Equal("valid"))
|
||||
Expect(user.UserName).To(Equal("admin"))
|
||||
})
|
||||
|
||||
It("fails authentication with wrong password", func() {
|
||||
r := newGetRequest("u=invalid", "", "", "")
|
||||
cp := authenticate(mockedUsers)(next)
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
@@ -221,6 +218,75 @@ var _ = Describe("Middlewares", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("validateUser", func() {
|
||||
var ds model.DataStore
|
||||
BeforeEach(func() {
|
||||
ds = &persistence.MockDataStore{}
|
||||
})
|
||||
|
||||
Context("Plaintext password", func() {
|
||||
It("authenticates with plaintext password ", func() {
|
||||
usr, err := validateUser(context.TODO(), ds, "admin", "wordpass", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails authentication with wrong password", func() {
|
||||
_, err := validateUser(context.TODO(), ds, "admin", "INVALID", "", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Encoded password", func() {
|
||||
It("authenticates with simple encoded password ", func() {
|
||||
usr, err := validateUser(context.TODO(), ds, "admin", "enc:776f726470617373", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Token based authentication", func() {
|
||||
It("authenticates with token based authentication", func() {
|
||||
usr, err := validateUser(context.TODO(), ds, "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails if salt is missing", func() {
|
||||
_, err := validateUser(context.TODO(), ds, "admin", "", "23b342970e25c7928831c3317edd0b67", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("JWT based authentication", func() {
|
||||
var validToken string
|
||||
BeforeEach(func() {
|
||||
u := &model.User{UserName: "admin"}
|
||||
var err error
|
||||
validToken, err = auth.CreateToken(u)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
It("authenticates with JWT token based authentication", func() {
|
||||
usr, err := validateUser(context.TODO(), ds, "admin", "", "", "", validToken)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails if JWT token is invalid", func() {
|
||||
_, err := validateUser(context.TODO(), ds, "admin", "", "", "", "invalid.token")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("fails if JWT token sub is different than username", func() {
|
||||
_, err := validateUser(context.TODO(), ds, "not_admin", "", "", "", validToken)
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockHandler struct {
|
||||
@@ -233,23 +299,6 @@ func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
mh.called = true
|
||||
}
|
||||
|
||||
type mockUsers struct {
|
||||
engine.Users
|
||||
username, password, token, salt, jwt string
|
||||
}
|
||||
|
||||
func (m *mockUsers) Authenticate(ctx context.Context, username, password, token, salt, jwt string) (*model.User, error) {
|
||||
m.username = username
|
||||
m.password = password
|
||||
m.token = token
|
||||
m.salt = salt
|
||||
m.jwt = jwt
|
||||
if username == "valid" {
|
||||
return &model.User{UserName: username, Password: password}, nil
|
||||
}
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
|
||||
type mockPlayers struct {
|
||||
engine.Players
|
||||
transcoding *model.Transcoding
|
||||
|
||||
@@ -25,7 +25,7 @@ func (c *PlaylistsController) GetPlaylists(w http.ResponseWriter, r *http.Reques
|
||||
allPls, err := c.pls.GetAll(r.Context())
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal error")
|
||||
}
|
||||
playlists := make([]responses.Playlist, len(allPls))
|
||||
for i, p := range allPls {
|
||||
@@ -39,13 +39,13 @@ func (c *PlaylistsController) GetPlaylists(w http.ResponseWriter, r *http.Reques
|
||||
playlists[i].Created = p.CreatedAt
|
||||
playlists[i].Changed = p.UpdatedAt
|
||||
}
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.Playlists = &responses.Playlists{Playlist: playlists}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *PlaylistsController) GetPlaylist(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id, err := RequiredParamString(r, "id", "id parameter required")
|
||||
id, err := requiredParamString(r, "id", "id parameter required")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -53,13 +53,13 @@ func (c *PlaylistsController) GetPlaylist(w http.ResponseWriter, r *http.Request
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
log.Error(r, err.Error(), "id", id)
|
||||
return nil, NewError(responses.ErrorDataNotFound, "Directory not found")
|
||||
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
|
||||
case err != nil:
|
||||
log.Error(r, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.Playlist = c.buildPlaylistWithSongs(r.Context(), pinfo)
|
||||
return response, nil
|
||||
}
|
||||
@@ -74,29 +74,29 @@ func (c *PlaylistsController) CreatePlaylist(w http.ResponseWriter, r *http.Requ
|
||||
err := c.pls.Create(r.Context(), playlistId, name, songIds)
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
return NewResponse(), nil
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (c *PlaylistsController) DeletePlaylist(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id, err := RequiredParamString(r, "id", "Required parameter id is missing")
|
||||
id, err := requiredParamString(r, "id", "Required parameter id is missing")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = c.pls.Delete(r.Context(), id)
|
||||
if err == model.ErrNotAuthorized {
|
||||
return nil, NewError(responses.ErrorAuthorizationFail)
|
||||
return nil, newError(responses.ErrorAuthorizationFail)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
return NewResponse(), nil
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (c *PlaylistsController) UpdatePlaylist(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
playlistId, err := RequiredParamString(r, "playlistId", "Required parameter playlistId is missing")
|
||||
playlistId, err := requiredParamString(r, "playlistId", "Required parameter playlistId is missing")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -118,20 +118,20 @@ func (c *PlaylistsController) UpdatePlaylist(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
err = c.pls.Update(r.Context(), playlistId, pname, songsToAdd, songIndexesToRemove)
|
||||
if err == model.ErrNotAuthorized {
|
||||
return nil, NewError(responses.ErrorAuthorizationFail)
|
||||
return nil, newError(responses.ErrorAuthorizationFail)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
return NewResponse(), nil
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
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)
|
||||
pls.Entry = toChildren(ctx, d.Entries)
|
||||
return pls
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,17 @@ package subsonic
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/server/subsonic/engine"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
type SearchingController struct {
|
||||
search engine.Search
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
type searchParams struct {
|
||||
@@ -24,14 +26,14 @@ type searchParams struct {
|
||||
songOffset int
|
||||
}
|
||||
|
||||
func NewSearchingController(search engine.Search) *SearchingController {
|
||||
return &SearchingController{search: search}
|
||||
func NewSearchingController(ds model.DataStore) *SearchingController {
|
||||
return &SearchingController{ds: ds}
|
||||
}
|
||||
|
||||
func (c *SearchingController) getParams(r *http.Request) (*searchParams, error) {
|
||||
var err error
|
||||
sp := &searchParams{}
|
||||
sp.query, err = RequiredParamString(r, "query", "Parameter query required")
|
||||
sp.query, err = requiredParamString(r, "query", "Parameter query required")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -44,22 +46,26 @@ func (c *SearchingController) getParams(r *http.Request) (*searchParams, error)
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
func (c *SearchingController) searchAll(r *http.Request, sp *searchParams) (engine.Entries, engine.Entries, engine.Entries) {
|
||||
as, err := c.search.SearchArtist(r.Context(), sp.query, sp.artistOffset, sp.artistCount)
|
||||
func (c *SearchingController) searchAll(r *http.Request, sp *searchParams) (model.MediaFiles, model.Albums, model.Artists) {
|
||||
q := sanitize.Accents(strings.ToLower(strings.TrimSuffix(sp.query, "*")))
|
||||
ctx := r.Context()
|
||||
|
||||
artists, err := c.ds.Artist(ctx).Search(q, sp.artistOffset, sp.artistCount)
|
||||
if err != nil {
|
||||
log.Error(r, "Error searching for Artists", err)
|
||||
log.Error(ctx, "Error searching for Artists", err)
|
||||
}
|
||||
als, err := c.search.SearchAlbum(r.Context(), sp.query, sp.albumOffset, sp.albumCount)
|
||||
albums, err := c.ds.Album(ctx).Search(q, sp.albumOffset, sp.albumCount)
|
||||
if err != nil {
|
||||
log.Error(r, "Error searching for Albums", err)
|
||||
log.Error(ctx, "Error searching for Albums", err)
|
||||
}
|
||||
mfs, err := c.search.SearchSong(r.Context(), sp.query, sp.songOffset, sp.songCount)
|
||||
mediaFiles, err := c.ds.MediaFile(ctx).Search(q, sp.songOffset, sp.songCount)
|
||||
if err != nil {
|
||||
log.Error(r, "Error searching for MediaFiles", err)
|
||||
log.Error(ctx, "Error searching for MediaFiles", err)
|
||||
}
|
||||
|
||||
log.Debug(r, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists", len(mfs), len(als), len(as)), "query", sp.query)
|
||||
return mfs, als, as
|
||||
log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists",
|
||||
len(mediaFiles), len(albums), len(artists)), "query", sp.query)
|
||||
return mediaFiles, albums, artists
|
||||
}
|
||||
|
||||
func (c *SearchingController) Search2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
@@ -69,11 +75,21 @@ func (c *SearchingController) Search2(w http.ResponseWriter, r *http.Request) (*
|
||||
}
|
||||
mfs, als, as := c.searchAll(r, sp)
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
searchResult2 := &responses.SearchResult2{}
|
||||
searchResult2.Artist = ToArtists(as)
|
||||
searchResult2.Album = ToChildren(r.Context(), als)
|
||||
searchResult2.Song = ToChildren(r.Context(), mfs)
|
||||
searchResult2.Artist = make([]responses.Artist, len(as))
|
||||
for i, artist := range as {
|
||||
searchResult2.Artist[i] = responses.Artist{
|
||||
Id: artist.ID,
|
||||
Name: artist.Name,
|
||||
AlbumCount: artist.AlbumCount,
|
||||
}
|
||||
if artist.Starred {
|
||||
searchResult2.Artist[i].Starred = &artist.StarredAt
|
||||
}
|
||||
}
|
||||
searchResult2.Album = childrenFromAlbums(r.Context(), als)
|
||||
searchResult2.Song = childrenFromMediaFiles(r.Context(), mfs)
|
||||
response.SearchResult2 = searchResult2
|
||||
return response, nil
|
||||
}
|
||||
@@ -85,22 +101,21 @@ func (c *SearchingController) Search3(w http.ResponseWriter, r *http.Request) (*
|
||||
}
|
||||
mfs, als, as := c.searchAll(r, sp)
|
||||
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
searchResult3 := &responses.SearchResult3{}
|
||||
searchResult3.Artist = make([]responses.ArtistID3, len(as))
|
||||
for i, e := range as {
|
||||
for i, artist := range as {
|
||||
searchResult3.Artist[i] = responses.ArtistID3{
|
||||
Id: e.Id,
|
||||
Name: e.Title,
|
||||
CoverArt: e.CoverArt,
|
||||
AlbumCount: e.AlbumCount,
|
||||
Id: artist.ID,
|
||||
Name: artist.Name,
|
||||
AlbumCount: artist.AlbumCount,
|
||||
}
|
||||
if !e.Starred.IsZero() {
|
||||
searchResult3.Artist[i].Starred = &e.Starred
|
||||
if artist.Starred {
|
||||
searchResult3.Artist[i].Starred = &artist.StarredAt
|
||||
}
|
||||
}
|
||||
searchResult3.Album = ToAlbums(r.Context(), als)
|
||||
searchResult3.Song = ToChildren(r.Context(), mfs)
|
||||
searchResult3.Album = childrenFromAlbums(r.Context(), als)
|
||||
searchResult3.Song = childrenFromMediaFiles(r.Context(), mfs)
|
||||
response.SearchResult3 = searchResult3
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/core"
|
||||
"github.com/deluan/navidrome/log"
|
||||
@@ -23,7 +25,8 @@ func NewStreamController(streamer core.MediaStreamer, archiver core.Archiver, ds
|
||||
}
|
||||
|
||||
func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id, err := RequiredParamString(r, "id", "id parameter required")
|
||||
ctx := r.Context()
|
||||
id, err := requiredParamString(r, "id", "id parameter required")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -31,7 +34,7 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
|
||||
format := utils.ParamString(r, "format")
|
||||
estimateContentLength := utils.ParamBool(r, "estimateContentLength", false)
|
||||
|
||||
stream, err := c.streamer.NewStream(r.Context(), id, format, maxBitRate)
|
||||
stream, err := c.streamer.NewStream(ctx, id, format, maxBitRate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -56,14 +59,14 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
|
||||
// if Client requests the estimated content-length, send it
|
||||
if estimateContentLength {
|
||||
length := strconv.Itoa(stream.EstimatedContentLength())
|
||||
log.Trace(r.Context(), "Estimated content-length", "contentLength", length)
|
||||
log.Trace(ctx, "Estimated content-length", "contentLength", length)
|
||||
w.Header().Set("Content-Length", length)
|
||||
}
|
||||
|
||||
if c, err := io.Copy(w, stream); err != nil {
|
||||
log.Error(r.Context(), "Error sending transcoded file", "id", id, err)
|
||||
log.Error(ctx, "Error sending transcoded file", "id", id, err)
|
||||
} else {
|
||||
log.Trace(r.Context(), "Success sending transcode file", "id", id, "size", c)
|
||||
log.Trace(ctx, "Success sending transcode file", "id", id, "size", c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,31 +74,47 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
|
||||
}
|
||||
|
||||
func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id, err := RequiredParamString(r, "id", "id parameter required")
|
||||
ctx := r.Context()
|
||||
id, err := requiredParamString(r, "id", "id parameter required")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isTrack, err := c.ds.MediaFile(r.Context()).Exists(id)
|
||||
entity, err := getEntityByID(ctx, c.ds, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isTrack {
|
||||
stream, err := c.streamer.NewStream(r.Context(), id, "raw", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
|
||||
} else {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=Navidrome-download.zip")
|
||||
setHeaders := func(name string) {
|
||||
name = strings.ReplaceAll(name, ",", "_")
|
||||
disposition := fmt.Sprintf("attachment; filename=\"%s.zip\"", name)
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
err := c.archiver.Zip(r.Context(), id, w)
|
||||
}
|
||||
|
||||
switch v := entity.(type) {
|
||||
case *model.MediaFile:
|
||||
stream, err := c.streamer.NewStream(ctx, id, "raw", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name())
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
|
||||
return nil, nil
|
||||
case *model.Album:
|
||||
setHeaders(v.Name)
|
||||
err = c.archiver.ZipAlbum(ctx, id, w)
|
||||
case *model.Artist:
|
||||
setHeaders(v.Name)
|
||||
err = c.archiver.ZipArtist(ctx, id, w)
|
||||
default:
|
||||
err = model.ErrNotFound
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ func NewSystemController() *SystemController {
|
||||
}
|
||||
|
||||
func (c *SystemController) Ping(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
return NewResponse(), nil
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (c *SystemController) GetLicense(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.License = &responses.License{Valid: true}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ func NewUsersController() *UsersController {
|
||||
|
||||
// TODO This is a placeholder. The real one has to read this info from a config file or the database
|
||||
func (c *UsersController) GetUser(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
user, err := RequiredParamString(r, "username", "Required string parameter 'username' is not present")
|
||||
user, err := requiredParamString(r, "username", "Required string parameter 'username' is not present")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := NewResponse()
|
||||
response := newResponse()
|
||||
response.User = &responses.User{}
|
||||
response.User.Username = user
|
||||
response.User.StreamRole = true
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/server/subsonic/engine"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
@@ -17,8 +18,8 @@ func initSystemController(router *Router) *SystemController {
|
||||
}
|
||||
|
||||
func initBrowsingController(router *Router) *BrowsingController {
|
||||
browser := router.Browser
|
||||
browsingController := NewBrowsingController(browser)
|
||||
dataStore := router.DataStore
|
||||
browsingController := NewBrowsingController(dataStore)
|
||||
return browsingController
|
||||
}
|
||||
|
||||
@@ -29,9 +30,9 @@ func initAlbumListController(router *Router) *AlbumListController {
|
||||
}
|
||||
|
||||
func initMediaAnnotationController(router *Router) *MediaAnnotationController {
|
||||
scrobbler := router.Scrobbler
|
||||
dataStore := router.DataStore
|
||||
mediaAnnotationController := NewMediaAnnotationController(scrobbler, dataStore)
|
||||
nowPlayingRepository := engine.NewNowPlayingRepository()
|
||||
mediaAnnotationController := NewMediaAnnotationController(dataStore, nowPlayingRepository)
|
||||
return mediaAnnotationController
|
||||
}
|
||||
|
||||
@@ -42,8 +43,8 @@ func initPlaylistsController(router *Router) *PlaylistsController {
|
||||
}
|
||||
|
||||
func initSearchingController(router *Router) *SearchingController {
|
||||
search := router.Search
|
||||
searchingController := NewSearchingController(search)
|
||||
dataStore := router.DataStore
|
||||
searchingController := NewSearchingController(dataStore)
|
||||
return searchingController
|
||||
}
|
||||
|
||||
@@ -84,6 +85,5 @@ var allProviders = wire.NewSet(
|
||||
NewUsersController,
|
||||
NewMediaRetrievalController,
|
||||
NewStreamController,
|
||||
NewBookmarksController, wire.FieldsOf(new(*Router), "Browser", "Artwork", "ListGenerator", "Playlists", "Scrobbler",
|
||||
"Search", "Streamer", "Archiver", "DataStore"),
|
||||
NewBookmarksController, engine.NewNowPlayingRepository, wire.FieldsOf(new(*Router), "Artwork", "ListGenerator", "Playlists", "Streamer", "Archiver", "DataStore"),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/server/subsonic/engine"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
@@ -17,8 +18,8 @@ var allProviders = wire.NewSet(
|
||||
NewMediaRetrievalController,
|
||||
NewStreamController,
|
||||
NewBookmarksController,
|
||||
wire.FieldsOf(new(*Router), "Browser", "Artwork", "ListGenerator", "Playlists", "Scrobbler",
|
||||
"Search", "Streamer", "Archiver", "DataStore"),
|
||||
engine.NewNowPlayingRepository,
|
||||
wire.FieldsOf(new(*Router), "Artwork", "ListGenerator", "Playlists", "Streamer", "Archiver", "DataStore"),
|
||||
)
|
||||
|
||||
func initSystemController(router *Router) *SystemController {
|
||||
|
||||
4411
ui/package-lock.json
generated
4411
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,13 +19,13 @@
|
||||
"react-ga": "^3.1.2",
|
||||
"react-jinke-music-player": "^4.16.5",
|
||||
"react-measure": "^2.3.0",
|
||||
"react-redux": "^7.2.0",
|
||||
"react-scripts": "^3.4.1"
|
||||
"react-redux": "^7.2.1",
|
||||
"react-scripts": "^3.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.11.1",
|
||||
"@testing-library/react": "^10.4.7",
|
||||
"@testing-library/user-event": "^12.0.11",
|
||||
"@testing-library/jest-dom": "^5.11.3",
|
||||
"@testing-library/react": "^10.4.8",
|
||||
"@testing-library/user-event": "^12.1.1",
|
||||
"prettier": "^2.0.5"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { linkToRecord, Loading } from 'react-admin'
|
||||
import { withContentRect } from 'react-measure'
|
||||
import subsonic from '../subsonic'
|
||||
import { ArtistLinkField, RangeField } from '../common'
|
||||
import AlbumContextMenu from '../common/AlbumContextMenu.js'
|
||||
import { AlbumContextMenu } from '../common'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
useTranslate,
|
||||
useListParams,
|
||||
} from 'react-admin'
|
||||
import { List, Title, useAlbumsPerPage } from '../common'
|
||||
import StarIcon from '@material-ui/icons/Star'
|
||||
import { withWidth } from '@material-ui/core'
|
||||
import { List, QuickFilter, Title, useAlbumsPerPage } from '../common'
|
||||
import AlbumListActions from './AlbumListActions'
|
||||
import AlbumListView from './AlbumListView'
|
||||
import AlbumGridView from './AlbumGridView'
|
||||
@@ -37,6 +38,11 @@ const AlbumFilter = (props) => {
|
||||
</ReferenceInput>
|
||||
<NullableBooleanInput source="compilation" />
|
||||
<NumberInput source="year" />
|
||||
<QuickFilter
|
||||
source="starred"
|
||||
label={<StarIcon fontSize={'small'} />}
|
||||
defaultValue={true}
|
||||
/>
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,14 +10,24 @@ import {
|
||||
SimpleShowLayout,
|
||||
TextField,
|
||||
} from 'react-admin'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
DurationField,
|
||||
RangeField,
|
||||
SimpleList,
|
||||
} from '../common'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import AlbumContextMenu from '../common/AlbumContextMenu'
|
||||
import { AlbumContextMenu } from '../common'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
columnIcon: {
|
||||
marginLeft: '3px',
|
||||
marginTop: '-2px',
|
||||
verticalAlign: 'text-top',
|
||||
},
|
||||
})
|
||||
|
||||
const AlbumDetails = (props) => {
|
||||
return (
|
||||
@@ -64,6 +74,7 @@ const AlbumDatagrid = (props) => (
|
||||
)
|
||||
|
||||
const AlbumListView = ({ hasShow, hasEdit, hasList, ...rest }) => {
|
||||
const classes = useStyles()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
return isXsmall ? (
|
||||
@@ -88,7 +99,14 @@ const AlbumListView = ({ hasShow, hasEdit, hasList, ...rest }) => {
|
||||
{isDesktop && <NumberField source="playCount" sortByOrder={'DESC'} />}
|
||||
<RangeField source={'year'} sortBy={'maxYear'} sortByOrder={'DESC'} />
|
||||
{isDesktop && <DurationField source="duration" />}
|
||||
<AlbumContextMenu />
|
||||
<AlbumContextMenu
|
||||
source={'starred'}
|
||||
sortBy={'starred ASC, starredAt ASC'}
|
||||
sortByOrder={'DESC'}
|
||||
label={
|
||||
<StarBorderIcon fontSize={'small'} className={classes.columnIcon} />
|
||||
}
|
||||
/>
|
||||
</AlbumDatagrid>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import LibraryAddIcon from '@material-ui/icons/LibraryAdd'
|
||||
import VideoLibraryIcon from '@material-ui/icons/VideoLibrary'
|
||||
import RepeatIcon from '@material-ui/icons/Repeat'
|
||||
import AlbumIcon from '@material-ui/icons/Album'
|
||||
import StarIcon from '@material-ui/icons/Star'
|
||||
|
||||
export default {
|
||||
all: {
|
||||
@@ -10,6 +11,10 @@ export default {
|
||||
params: 'sort=name&order=ASC',
|
||||
},
|
||||
random: { icon: ShuffleIcon, params: 'sort=random' },
|
||||
starred: {
|
||||
icon: StarIcon,
|
||||
params: 'sort=starred_at&order=DESC&filter={"starred":true}',
|
||||
},
|
||||
recentlyAdded: {
|
||||
icon: LibraryAddIcon,
|
||||
params: 'sort=created_at&order=DESC',
|
||||
|
||||
@@ -1,36 +1,118 @@
|
||||
import React from 'react'
|
||||
import React, { cloneElement, isValidElement, useState } from 'react'
|
||||
import {
|
||||
Datagrid,
|
||||
DatagridBody,
|
||||
DatagridRow,
|
||||
Filter,
|
||||
NumberField,
|
||||
SearchInput,
|
||||
TextField,
|
||||
} from 'react-admin'
|
||||
import { List, useGetHandleArtistClick } from '../common'
|
||||
import { withWidth } from '@material-ui/core'
|
||||
import { useMediaQuery, withWidth } from '@material-ui/core'
|
||||
import StarIcon from '@material-ui/icons/Star'
|
||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import AddToPlaylistDialog from '../dialogs/AddToPlaylistDialog'
|
||||
import {
|
||||
ArtistContextMenu,
|
||||
List,
|
||||
QuickFilter,
|
||||
SimpleList,
|
||||
useGetHandleArtistClick,
|
||||
} from '../common'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
columnIcon: {
|
||||
marginLeft: '3px',
|
||||
marginTop: '-2px',
|
||||
verticalAlign: 'text-top',
|
||||
},
|
||||
})
|
||||
|
||||
const ArtistFilter = (props) => (
|
||||
<Filter {...props}>
|
||||
<SearchInput source="name" alwaysOn />
|
||||
<QuickFilter
|
||||
source="starred"
|
||||
label={<StarIcon fontSize={'small'} />}
|
||||
defaultValue={true}
|
||||
/>
|
||||
</Filter>
|
||||
)
|
||||
|
||||
const ArtistList = ({ width, ...props }) => {
|
||||
const handleArtistLink = useGetHandleArtistClick(width)
|
||||
const ArtistDatagridRow = ({ children, ...rest }) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const childCount = React.Children.count(children)
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
exporter={false}
|
||||
bulkActionButtons={false}
|
||||
filters={<ArtistFilter />}
|
||||
<DatagridRow
|
||||
onMouseEnter={() => setVisible(true)}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
{...rest}
|
||||
>
|
||||
<Datagrid rowClick={handleArtistLink}>
|
||||
<TextField source="name" />
|
||||
<NumberField source="albumCount" sortByOrder={'DESC'} />
|
||||
<NumberField source="songCount" sortByOrder={'DESC'} />
|
||||
</Datagrid>
|
||||
</List>
|
||||
{React.Children.map(
|
||||
children,
|
||||
(child, index) =>
|
||||
child &&
|
||||
isValidElement(child) &&
|
||||
(index < childCount - 1
|
||||
? child
|
||||
: cloneElement(child, {
|
||||
visible,
|
||||
}))
|
||||
)}
|
||||
</DatagridRow>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistDatagridBody = (props) => (
|
||||
<DatagridBody {...props} row={<ArtistDatagridRow />} />
|
||||
)
|
||||
const ArtistDatagrid = (props) => (
|
||||
<Datagrid {...props} body={<ArtistDatagridBody />} />
|
||||
)
|
||||
|
||||
const ArtistList = ({ width, ...rest }) => {
|
||||
const classes = useStyles()
|
||||
const handleArtistLink = useGetHandleArtistClick(width)
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
return (
|
||||
<>
|
||||
<List
|
||||
{...rest}
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
exporter={false}
|
||||
bulkActionButtons={false}
|
||||
filters={<ArtistFilter />}
|
||||
>
|
||||
{isXsmall ? (
|
||||
<SimpleList
|
||||
primaryText={(r) => r.name}
|
||||
linkType={'show'}
|
||||
rightIcon={(r) => <ArtistContextMenu record={r} />}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<ArtistDatagrid rowClick={handleArtistLink}>
|
||||
<TextField source="name" />
|
||||
<NumberField source="albumCount" sortByOrder={'DESC'} />
|
||||
<NumberField source="songCount" sortByOrder={'DESC'} />
|
||||
<NumberField source="playCount" sortByOrder={'DESC'} />
|
||||
<ArtistContextMenu
|
||||
source={'starred'}
|
||||
sortBy={'starred ASC, starredAt ASC'}
|
||||
sortByOrder={'DESC'}
|
||||
label={
|
||||
<StarBorderIcon
|
||||
fontSize={'small'}
|
||||
className={classes.columnIcon}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ArtistDatagrid>
|
||||
)}
|
||||
</List>
|
||||
<AddToPlaylistDialog />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,15 @@ import Menu from '@material-ui/core/Menu'
|
||||
import MenuItem from '@material-ui/core/MenuItem'
|
||||
import MoreVertIcon from '@material-ui/icons/MoreVert'
|
||||
import StarIcon from '@material-ui/icons/Star'
|
||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useDataProvider, useNotify, useTranslate } from 'react-admin'
|
||||
import {
|
||||
useDataProvider,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
useUpdate,
|
||||
} from 'react-admin'
|
||||
import { addTracks, playTracks, shuffleTracks } from '../audioplayer'
|
||||
import { openAddToPlaylist } from '../dialogs/dialogState'
|
||||
import subsonic from '../subsonic'
|
||||
@@ -21,36 +28,50 @@ const useStyles = makeStyles({
|
||||
visibility: (props) => (props.visible ? 'visible' : 'hidden'),
|
||||
},
|
||||
star: {
|
||||
visibility: 'hidden', // TODO: Invisible for now
|
||||
visibility: (props) =>
|
||||
props.visible || props.starred ? 'visible' : 'hidden',
|
||||
},
|
||||
})
|
||||
|
||||
const AlbumContextMenu = ({ record, discNumber, color, visible }) => {
|
||||
const classes = useStyles({ color, visible })
|
||||
const ContextMenu = ({
|
||||
resource,
|
||||
showStar,
|
||||
record,
|
||||
color,
|
||||
visible,
|
||||
songQueryParams,
|
||||
}) => {
|
||||
const classes = useStyles({ color, visible, starred: record.starred })
|
||||
const dataProvider = useDataProvider()
|
||||
const dispatch = useDispatch()
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
const refresh = useRefresh()
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
|
||||
const options = {
|
||||
play: {
|
||||
needData: true,
|
||||
label: 'resources.album.actions.playAll',
|
||||
action: (data, ids) => dispatch(playTracks(data, ids)),
|
||||
},
|
||||
addToQueue: {
|
||||
needData: true,
|
||||
label: 'resources.album.actions.addToQueue',
|
||||
action: (data, ids) => dispatch(addTracks(data, ids)),
|
||||
},
|
||||
shuffle: {
|
||||
needData: true,
|
||||
label: 'resources.album.actions.shuffle',
|
||||
action: (data, ids) => dispatch(shuffleTracks(data, ids)),
|
||||
},
|
||||
addToPlaylist: {
|
||||
needData: true,
|
||||
label: 'resources.album.actions.addToPlaylist',
|
||||
action: (data, ids) => dispatch(openAddToPlaylist({ selectedIds: ids })),
|
||||
},
|
||||
download: {
|
||||
needData: false,
|
||||
label: 'resources.album.actions.download',
|
||||
action: () => subsonic.download(record.id),
|
||||
},
|
||||
@@ -80,30 +101,64 @@ const AlbumContextMenu = ({ record, discNumber, color, visible }) => {
|
||||
const handleItemClick = (e) => {
|
||||
setAnchorEl(null)
|
||||
const key = e.target.getAttribute('value')
|
||||
dataProvider
|
||||
.getList('albumSong', {
|
||||
pagination: { page: 1, perPage: -1 },
|
||||
sort: { field: 'discNumber, trackNumber', order: 'ASC' },
|
||||
filter: { album_id: record.id, disc_number: discNumber },
|
||||
})
|
||||
.then((response) => {
|
||||
let { data, ids } = extractSongsData(response)
|
||||
options[key].action(data, ids)
|
||||
})
|
||||
.catch(() => {
|
||||
notify('ra.page.error', 'warning')
|
||||
})
|
||||
if (options[key].needData) {
|
||||
dataProvider
|
||||
.getList('albumSong', songQueryParams)
|
||||
.then((response) => {
|
||||
let { data, ids } = extractSongsData(response)
|
||||
options[key].action(data, ids)
|
||||
})
|
||||
.catch(() => {
|
||||
notify('ra.page.error', 'warning')
|
||||
})
|
||||
} else {
|
||||
options[key].action()
|
||||
}
|
||||
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const [toggleStarred, { loading: updating }] = useUpdate(
|
||||
resource,
|
||||
record.id,
|
||||
{
|
||||
...record,
|
||||
starred: !record.starred,
|
||||
},
|
||||
{
|
||||
undoable: false,
|
||||
onFailure: (error) => {
|
||||
console.log(error)
|
||||
notify('ra.page.error', 'warning')
|
||||
refresh()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const handleToggleStar = (e) => {
|
||||
e.preventDefault()
|
||||
toggleStarred()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const open = Boolean(anchorEl)
|
||||
|
||||
return (
|
||||
<span className={classes.noWrap}>
|
||||
<IconButton size={'small'} className={classes.star}>
|
||||
<StarIcon fontSize={'small'} />
|
||||
</IconButton>
|
||||
{showStar && (
|
||||
<IconButton
|
||||
onClick={handleToggleStar}
|
||||
size={'small'}
|
||||
disabled={updating}
|
||||
className={classes.star}
|
||||
>
|
||||
{record.starred ? (
|
||||
<StarIcon fontSize={'small'} />
|
||||
) : (
|
||||
<StarBorderIcon fontSize={'small'} />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
aria-label="more"
|
||||
aria-controls="context-menu"
|
||||
@@ -131,16 +186,53 @@ const AlbumContextMenu = ({ record, discNumber, color, visible }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const AlbumContextMenu = (props) => (
|
||||
<ContextMenu
|
||||
{...props}
|
||||
resource={'album'}
|
||||
songQueryParams={{
|
||||
pagination: { page: 1, perPage: -1 },
|
||||
sort: { field: 'discNumber, trackNumber', order: 'ASC' },
|
||||
filter: { album_id: props.record.id, disc_number: props.discNumber },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
AlbumContextMenu.propTypes = {
|
||||
record: PropTypes.object,
|
||||
discNumber: PropTypes.number,
|
||||
visible: PropTypes.bool,
|
||||
color: PropTypes.string,
|
||||
showStar: PropTypes.bool,
|
||||
}
|
||||
|
||||
AlbumContextMenu.defaultProps = {
|
||||
visible: true,
|
||||
showStar: true,
|
||||
addLabel: true,
|
||||
}
|
||||
|
||||
export default AlbumContextMenu
|
||||
export const ArtistContextMenu = (props) => (
|
||||
<ContextMenu
|
||||
{...props}
|
||||
resource={'artist'}
|
||||
songQueryParams={{
|
||||
pagination: { page: 1, perPage: 200 },
|
||||
sort: { field: 'album, discNumber, trackNumber', order: 'ASC' },
|
||||
filter: { album_artist_id: props.record.id },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
ArtistContextMenu.propTypes = {
|
||||
record: PropTypes.object,
|
||||
visible: PropTypes.bool,
|
||||
color: PropTypes.string,
|
||||
showStar: PropTypes.bool,
|
||||
}
|
||||
|
||||
ArtistContextMenu.defaultProps = {
|
||||
visible: true,
|
||||
showStar: true,
|
||||
addLabel: true,
|
||||
}
|
||||
50
ui/src/common/ShuffleAllButton.js
Normal file
50
ui/src/common/ShuffleAllButton.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import { Button, useDataProvider, useNotify, useTranslate } from 'react-admin'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import ShuffleIcon from '@material-ui/icons/Shuffle'
|
||||
import { playTracks } from '../audioplayer'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const ShuffleAllButton = ({ filters }) => {
|
||||
const translate = useTranslate()
|
||||
const dataProvider = useDataProvider()
|
||||
const dispatch = useDispatch()
|
||||
const notify = useNotify()
|
||||
|
||||
const handleOnClick = () => {
|
||||
dataProvider
|
||||
.getList('song', {
|
||||
pagination: { page: 1, perPage: 200 },
|
||||
sort: { field: 'random', order: 'ASC' },
|
||||
filter: filters,
|
||||
})
|
||||
.then((res) => {
|
||||
const data = {}
|
||||
res.data.forEach((song) => {
|
||||
data[song.id] = song
|
||||
})
|
||||
dispatch(playTracks(data))
|
||||
})
|
||||
.catch(() => {
|
||||
notify('ra.page.error', 'warning')
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleOnClick}
|
||||
label={translate('resources.song.actions.shuffleAll')}
|
||||
>
|
||||
<ShuffleIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
ShuffleAllButton.propTypes = {
|
||||
filters: PropTypes.object,
|
||||
}
|
||||
ShuffleAllButton.defaultProps = {
|
||||
filters: {},
|
||||
}
|
||||
|
||||
export default ShuffleAllButton
|
||||
@@ -9,6 +9,7 @@ import StarIcon from '@material-ui/icons/Star'
|
||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import { addTracks, setTrack } from '../audioplayer'
|
||||
import { openAddToPlaylist } from '../dialogs/dialogState'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
noWrap: {
|
||||
@@ -39,19 +40,25 @@ const SongContextMenu = ({
|
||||
const options = {
|
||||
playNow: {
|
||||
label: 'resources.song.actions.playNow',
|
||||
action: (record) => setTrack(record),
|
||||
action: (record) => dispatch(setTrack(record)),
|
||||
},
|
||||
addToQueue: {
|
||||
label: 'resources.song.actions.addToQueue',
|
||||
action: (record) => addTracks({ [record.id]: record }),
|
||||
action: (record) => dispatch(addTracks({ [record.id]: record })),
|
||||
},
|
||||
addToPlaylist: {
|
||||
label: 'resources.song.actions.addToPlaylist',
|
||||
action: (record) =>
|
||||
openAddToPlaylist({
|
||||
selectedIds: [record.mediaFileId || record.id],
|
||||
onSuccess: (id) => onAddToPlaylist(id),
|
||||
}),
|
||||
dispatch(
|
||||
openAddToPlaylist({
|
||||
selectedIds: [record.mediaFileId || record.id],
|
||||
onSuccess: (id) => onAddToPlaylist(id),
|
||||
})
|
||||
),
|
||||
},
|
||||
download: {
|
||||
label: 'resources.song.actions.download',
|
||||
action: (record) => subsonic.download(record.id),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -69,7 +76,7 @@ const SongContextMenu = ({
|
||||
e.preventDefault()
|
||||
setAnchorEl(null)
|
||||
const key = e.target.getAttribute('value')
|
||||
dispatch(options[key].action(record))
|
||||
options[key].action(record)
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import PropTypes from 'prop-types'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import AlbumIcon from '@material-ui/icons/Album'
|
||||
import { playTracks } from '../audioplayer'
|
||||
import AlbumContextMenu from './AlbumContextMenu'
|
||||
import { AlbumContextMenu } from '../common'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
row: {
|
||||
|
||||
@@ -15,6 +15,8 @@ import SongContextMenu from './SongContextMenu'
|
||||
import SongTitleField from './SongTitleField'
|
||||
import QuickFilter from './QuickFilter'
|
||||
import useAlbumsPerPage from './useAlbumsPerPage'
|
||||
import ShuffleAllButton from './ShuffleAllButton'
|
||||
import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus'
|
||||
|
||||
export {
|
||||
Title,
|
||||
@@ -33,8 +35,11 @@ export {
|
||||
DocLink,
|
||||
formatRange,
|
||||
ArtistLinkField,
|
||||
AlbumContextMenu,
|
||||
ArtistContextMenu,
|
||||
useGetHandleArtistClick,
|
||||
SongContextMenu,
|
||||
QuickFilter,
|
||||
useAlbumsPerPage,
|
||||
ShuffleAllButton,
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"addToQueue": "Play Later",
|
||||
"addToPlaylist": "Add to Playlist",
|
||||
"playNow": "Play Now",
|
||||
"shuffleAll": "Shuffle All"
|
||||
"shuffleAll": "Shuffle All",
|
||||
"download": "Download"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -53,6 +54,7 @@
|
||||
"lists": {
|
||||
"all": "All",
|
||||
"random": "Random",
|
||||
"starred": "Starred",
|
||||
"recentlyAdded": "Recently Added",
|
||||
"recentlyPlayed": "Recently Played",
|
||||
"mostPlayed": "Most Played"
|
||||
@@ -63,7 +65,8 @@
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"albumCount": "Album Count",
|
||||
"songCount": "Song Count"
|
||||
"songCount": "Song Count",
|
||||
"playCount": "Plays"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
|
||||
@@ -1,50 +1,6 @@
|
||||
import React, { cloneElement } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import {
|
||||
Button,
|
||||
sanitizeListRestProps,
|
||||
TopToolbar,
|
||||
useDataProvider,
|
||||
useTranslate,
|
||||
useNotify,
|
||||
} from 'react-admin'
|
||||
import ShuffleIcon from '@material-ui/icons/Shuffle'
|
||||
import { playTracks } from '../audioplayer'
|
||||
|
||||
const ShuffleAllButton = () => {
|
||||
const translate = useTranslate()
|
||||
const dataProvider = useDataProvider()
|
||||
const dispatch = useDispatch()
|
||||
const notify = useNotify()
|
||||
|
||||
const handleOnClick = () => {
|
||||
dataProvider
|
||||
.getList('song', {
|
||||
pagination: { page: 1, perPage: 200 },
|
||||
sort: { field: 'random', order: 'ASC' },
|
||||
filter: {},
|
||||
})
|
||||
.then((res) => {
|
||||
const data = {}
|
||||
res.data.forEach((song) => {
|
||||
data[song.id] = song
|
||||
})
|
||||
dispatch(playTracks(data))
|
||||
})
|
||||
.catch(() => {
|
||||
notify('ra.page.error', 'warning')
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleOnClick}
|
||||
label={translate('resources.song.actions.shuffleAll')}
|
||||
>
|
||||
<ShuffleIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
import { sanitizeListRestProps, TopToolbar } from 'react-admin'
|
||||
import { ShuffleAllButton } from '../common'
|
||||
|
||||
export const SongListActions = ({
|
||||
currentSort,
|
||||
@@ -74,7 +30,7 @@ export const SongListActions = ({
|
||||
filterValues,
|
||||
context: 'button',
|
||||
})}
|
||||
<ShuffleAllButton />
|
||||
<ShuffleAllButton filters={filterValues} />
|
||||
</TopToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@ const url = (command, id, options) => {
|
||||
const scrobble = (id, submit) =>
|
||||
fetchUtils.fetchJson(url('scrobble', id, { submission: submit }))
|
||||
|
||||
const download = (id, submit) => (window.location.href = url('download', id))
|
||||
const download = (id) => (window.location.href = url('download', id))
|
||||
|
||||
export default { url, scrobble, download }
|
||||
|
||||
Reference in New Issue
Block a user