Compare commits

...

40 Commits

Author SHA1 Message Date
Deluan
a2e0acd6a2 Fix starring albums. Seems I may have lost a commit? 2020-08-15 15:03:03 -04:00
Deluan
5f38e70a2b Bump react-redux to 7.2.1 2020-08-15 12:58:22 -04:00
Deluan
c19c599521 Bump @testing-library 2020-08-15 12:57:18 -04:00
Deluan
dd398224e7 go mod tidy 2020-08-15 10:48:56 -04:00
Deluan
5ac76ae7e0 Fix broken image href 2020-08-14 17:00:24 -04:00
Deluan
c14147e6c5 More updated screenshots 2020-08-14 16:59:45 -04:00
Deluan
59ce940cd6 Use new screenshot in README 2020-08-14 16:53:36 -04:00
Deluan
cfecd7c6a2 Add new screenshot 2020-08-14 16:52:54 -04:00
Deluan
d81a4472a0 Update Czech translation 2020-08-14 16:32:30 -04:00
Deluan
147d26fb75 Enable sort by "starred" in Album and Artist lists 2020-08-14 15:35:15 -04:00
Deluan
848318932d Remove unused import 2020-08-14 14:47:54 -04:00
Deluan
49153dc1c1 Add playCount to artist list 2020-08-14 14:35:00 -04:00
Deluan
ca5da5b0ea Use active filters when shuffling songs 2020-08-14 14:10:39 -04:00
Deluan
c2e03c8162 Add stars to Albums 2020-08-14 13:35:28 -04:00
Deluan
f2ebbd26fa Add stars to Artist 2020-08-14 13:19:32 -04:00
Deluan
bbc4f9f91f Add artist context menu 2020-08-14 12:55:22 -04:00
Deluan
6fe1f84c68 Add download for songs 2020-08-14 12:11:35 -04:00
Deluan
d72468003f User album or artist name as zip name in download endpoint 2020-08-14 12:10:37 -04:00
Deluan
100f6a0645 Removed engine.Users 2020-08-14 12:10:37 -04:00
Deluan
bc2073fbd5 Removed unused function 2020-08-14 12:10:37 -04:00
Deluan
278d0ea8f3 Fix album fields in simulated browsing by folder 2020-08-14 12:10:37 -04:00
Deluan
0e16d7cfbb Fix regression: Show artwork in Music Stash when browsing by folder 2020-08-14 12:10:37 -04:00
Deluan
419884db7c Removed engine.Scrobbler 2020-08-14 12:10:37 -04:00
Deluan
eacfc41665 Removed engine.Search 2020-08-14 12:10:37 -04:00
Deluan
c271aa24d1 Make all Subsonic helper functions private 2020-08-14 12:10:37 -04:00
Deluan
22f34b3347 Refactor getGenres. Remove engine.Browser 2020-08-14 12:10:37 -04:00
Deluan
eba8395146 Refactor getSong 2020-08-14 12:10:37 -04:00
Deluan
f16dc5f8f8 Refactor getMusicDirectory 2020-08-14 12:10:37 -04:00
Deluan
15c8f4c0ef Refactor getAlbum 2020-08-14 12:10:37 -04:00
Deluan
e344f616b3 Refactor getArtist 2020-08-14 12:10:37 -04:00
Deluan
ef81caf3ed Refactor getMusicFolders and getIndexes 2020-08-14 12:10:37 -04:00
dependabot-preview[bot]
8513f1a899 Bump github.com/spf13/viper from 1.7.0 to 1.7.1
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.7.0 to 1.7.1.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.7.0...v1.7.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-08-14 09:44:35 -04:00
dependabot-preview[bot]
a9a25713e8 Bump github.com/microcosm-cc/bluemonday from 1.0.3 to 1.0.4
Bumps [github.com/microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday) from 1.0.3 to 1.0.4.
- [Release notes](https://github.com/microcosm-cc/bluemonday/releases)
- [Commits](https://github.com/microcosm-cc/bluemonday/compare/v1.0.3...v1.0.4)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-08-14 08:01:50 -04:00
Deluan
a5e1986072 Fix getTopSongs endpoint 2020-08-13 18:56:13 -04:00
Deluan Quintão
97c98e3369 Update tr.json (POEditor.com) 2020-08-13 17:19:25 -04:00
Deluan Quintão
6effd603e2 Update de.json (POEditor.com) 2020-08-13 17:19:25 -04:00
Deluan Quintão
8a783ef967 Update fr.json (POEditor.com) 2020-08-13 17:19:25 -04:00
Deluan
b74bd30b72 Fix Security Issue CVE-2020-7660 2020-08-13 11:14:13 -04:00
Deluan Quintão
9fa09e41cc Update README.md 2020-08-11 16:05:23 -04:00
Deluan
4ef12f91e0 Support Linux 32 bits releases 2020-08-07 13:36:00 -04:00
58 changed files with 2719 additions and 3843 deletions

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 5.1 MiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 283 KiB

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
# Navidrome Music Streamer
# Navidrome Music Server
[![Last Release](https://img.shields.io/github/v/release/deluan/navidrome?label=latest&style=flat-square)](https://github.com/deluan/navidrome/releases)
[![Build](https://img.shields.io/github/workflow/status/deluan/navidrome/Build?style=flat-square)](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)

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 lexé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",

View File

@@ -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": {

View File

@@ -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ı",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,8 @@ import (
)
var Set = wire.NewSet(
NewBrowser,
NewListGenerator,
NewPlaylists,
NewScrobbler,
NewSearch,
NewNowPlayingRepository,
NewUsers,
NewPlayers,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

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