Compare commits

...

17 Commits

Author SHA1 Message Date
Deluan
0730c667a2 Add "Shuffle All" option to Song List. Closes #256 2020-08-07 10:47:55 -04:00
Deluan
4ec451aecb Add content-disposition header to set a download name 2020-08-05 18:40:46 -04:00
Deluan
883dd7f728 Use Outlined download icon
Also remove dangling console.log
2020-08-05 15:25:59 -04:00
Deluan
38c19eddc3 Add 'download' option to album context menu 2020-08-05 14:57:59 -04:00
Deluan
8e4b2e1c06 Add GetTopSongs placeholder, to make AVSub work 2020-08-05 13:48:50 -04:00
Deluan
a541afbfba Revert "Return absolute paths in Subsonic API responses"
This reverts commit 338cbacb
2020-08-05 12:37:43 -04:00
Deluan
df05760769 Move engine package under subsonic, as it should only be used by the Subsonic API.master
The idea is to move reusable code from `engine` to `core`, in future refactorings
2020-08-04 21:29:35 -04:00
Deluan
9a1133601a Store uncompressed files in zip 2020-08-04 13:38:32 -04:00
Deluan
2c370cae28 Support downloading full album and artist discography through Subsonic API 2020-08-04 12:39:13 -04:00
Deluan
f745b8d223 Use transaction's DataStore 2020-08-04 11:53:19 -04:00
Deluan
f1b6703ab0 Update React Player, fix song title maxWidth
See https://github.com/lijinke666/react-music-player/issues/141
2020-08-04 08:41:30 -04:00
Deluan
28d1428c90 Add option to disable .m3u auto-import 2020-08-02 23:17:13 -04:00
Deluan
696a0feb31 Remove ratings from engine package 2020-08-02 17:58:07 -04:00
Deluan
f29e1eb248 Remove repeated call 2020-08-02 15:19:42 -04:00
Deluan
d4e599233e Increase timeout of lint job in pipeline 2020-08-02 14:53:47 -04:00
Deluan
aaec8e080b Remove unused code 2020-08-02 11:16:46 -04:00
Deluan Quintão
09442eccd4 Update README.md 2020-08-01 23:29:27 -04:00
57 changed files with 455 additions and 220 deletions

View File

@@ -19,6 +19,7 @@ jobs:
with:
version: v1.27
github-token: ${{ secrets.GITHUB_TOKEN }}
args: --timeout 2m
go:
name: Test Server on ${{ matrix.os }}

View File

@@ -12,7 +12,7 @@ Navidrome is an open source web-based music collection server and streamer. It g
music collection from any browser or mobile device. It's like your personal Spotify!
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the discussion in our
please file a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the discussion in our
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
([ui/backend dev](https://www.navidrome.org/docs/developers/),
[translations](https://www.navidrome.org/docs/developers/translations/),

View File

@@ -83,6 +83,7 @@ func init() {
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))

View File

@@ -8,12 +8,12 @@ package cmd
import (
"github.com/deluan/navidrome/core"
"github.com/deluan/navidrome/core/transcoder"
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/persistence"
"github.com/deluan/navidrome/scanner"
"github.com/deluan/navidrome/server"
"github.com/deluan/navidrome/server/app"
"github.com/deluan/navidrome/server/subsonic"
"github.com/deluan/navidrome/server/subsonic/engine"
"github.com/google/wire"
)
@@ -47,14 +47,14 @@ func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
listGenerator := engine.NewListGenerator(dataStore, nowPlayingRepository)
users := engine.NewUsers(dataStore)
playlists := engine.NewPlaylists(dataStore)
ratings := engine.NewRatings(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, ratings, scrobbler, search, mediaStreamer, players, dataStore)
router := subsonic.New(browser, artwork, listGenerator, users, playlists, scrobbler, search, mediaStreamer, archiver, players, dataStore)
return router, nil
}

View File

@@ -4,12 +4,12 @@ package cmd
import (
"github.com/deluan/navidrome/core"
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/persistence"
"github.com/deluan/navidrome/scanner"
"github.com/deluan/navidrome/server"
"github.com/deluan/navidrome/server/app"
"github.com/deluan/navidrome/server/subsonic"
"github.com/deluan/navidrome/server/subsonic/engine"
"github.com/google/wire"
)

View File

@@ -26,6 +26,7 @@ type configOptions struct {
EnableTranscodingConfig bool
TranscodingCacheSize string
ImageCacheSize string
AutoImportPlaylists bool
IgnoredArticles string
IndexGroups string
@@ -83,6 +84,7 @@ func init() {
viper.SetDefault("enabletranscodingconfig", false)
viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("autoimportplaylists", true)
// Config options only valid for file/env configuration
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")

93
core/archiver.go Normal file
View File

@@ -0,0 +1,93 @@
package core
import (
"archive/zip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"github.com/Masterminds/squirrel"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
)
type Archiver interface {
Zip(ctx context.Context, id string, w io.Writer) error
}
func NewArchiver(ds model.DataStore) Archiver {
return &archiver{ds: ds}
}
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)
if err != nil {
log.Error(ctx, "Error loading media", "id", id, err)
return err
}
z := zip.NewWriter(out)
for _, mf := range mfs {
_ = a.addFileToZip(ctx, z, mf)
}
err = z.Close()
if err != nil {
log.Error(ctx, "Error closing zip file", "id", id, err)
}
return err
}
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile) error {
_, file := filepath.Split(mf.Path)
w, err := z.CreateHeader(&zip.FileHeader{
Name: fmt.Sprintf("%s/%s", mf.Album, file),
Modified: mf.UpdatedAt,
Method: zip.Store,
})
if err != nil {
log.Error(ctx, "Error creating zip entry", "file", mf.Path, err)
return err
}
f, err := os.Open(mf.Path)
defer func() { _ = f.Close() }()
if err != nil {
log.Error(ctx, "Error opening file for zipping", "file", mf.Path, err)
return err
}
_, err = io.Copy(w, f)
if err != nil {
log.Error(ctx, "Error zipping file", "file", mf.Path, err)
return err
}
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

@@ -10,5 +10,6 @@ var Set = wire.NewSet(
NewMediaStreamer,
NewTranscodingCache,
NewImageCache,
NewArchiver,
transcoder.New,
)

View File

@@ -1,71 +0,0 @@
package engine
import (
"context"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
)
type Ratings interface {
SetStar(ctx context.Context, star bool, ids ...string) error
SetRating(ctx context.Context, id string, rating int) error
}
func NewRatings(ds model.DataStore) Ratings {
return &ratings{ds}
}
type ratings struct {
ds model.DataStore
}
func (r ratings) SetRating(ctx context.Context, id string, rating int) error {
exist, err := r.ds.Album(ctx).Exists(id)
if err != nil {
return err
}
if exist {
return r.ds.Album(ctx).SetRating(rating, id)
}
return r.ds.MediaFile(ctx).SetRating(rating, id)
}
func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error {
if len(ids) == 0 {
log.Warn(ctx, "Cannot star/unstar an empty list of ids")
return nil
}
return r.ds.WithTx(func(tx model.DataStore) error {
for _, id := range ids {
exist, err := r.ds.Album(ctx).Exists(id)
if err != nil {
return err
}
if exist {
err = tx.Album(ctx).SetStar(star, ids...)
if err != nil {
return err
}
continue
}
exist, err = r.ds.Artist(ctx).Exists(id)
if err != nil {
return err
}
if exist {
err = tx.Artist(ctx).SetStar(star, ids...)
if err != nil {
return err
}
continue
}
err = tx.MediaFile(ctx).SetStar(star, ids...)
if err != nil {
return err
}
}
return nil
})
}

View File

@@ -23,7 +23,8 @@
"actions": {
"addToQueue": "Adicionar à fila",
"playNow": "Tocar agora",
"addToPlaylist": "Adicionar à playlist"
"addToPlaylist": "Adicionar à playlist",
"shuffleAll": "Aleatório"
}
},
"album": {
@@ -44,7 +45,9 @@
"playAll": "Tocar",
"playNext": "Tocar em seguida",
"addToQueue": "Adicionar à fila",
"shuffle": "Aleatório"
"shuffle": "Aleatório",
"addToPlaylist": "Adicionar à playlist",
"download": "Baixar"
},
"lists": {
"all": "Todos",

View File

@@ -62,7 +62,7 @@ func (s *mediaFileMapper) toMediaFile(md *Metadata) model.MediaFile {
func sanitizeFieldForSorting(originalValue string) string {
v := utils.NoArticle(originalValue)
v = strings.TrimSpace(sanitize.Accents(v))
return utils.NoArticle(v)
return v
}
func (s *mediaFileMapper) mapTrackTitle(md *Metadata) string {

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
@@ -104,19 +105,23 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) erro
}
}
// Now that all mediafiles are imported/updated, search for and import playlists
u, _ := request.UserFrom(ctx)
plsCount := 0
for _, dir := range changedDirs {
info := allFSDirs[dir]
if info.hasPlaylist {
if !u.IsAdmin {
log.Warn("Playlists will not be imported, as there are no admin users yet, "+
"Please create an admin user first, and then update the playlists for them to be imported", "dir", dir)
} else {
plsCount = s.plsSync.processPlaylists(ctx, dir)
if conf.Server.AutoImportPlaylists {
// Now that all mediafiles are imported/updated, search for and import playlists
u, _ := request.UserFrom(ctx)
for _, dir := range changedDirs {
info := allFSDirs[dir]
if info.hasPlaylist {
if !u.IsAdmin {
log.Warn("Playlists will not be imported, as there are no admin users yet, "+
"Please create an admin user first, and then update the playlists for them to be imported", "dir", dir)
} else {
plsCount = s.plsSync.processPlaylists(ctx, dir)
}
}
}
} else {
log.Debug("Playlist auto-import is disabled")
}
err = s.ds.GC(log.NewContext(ctx))

View File

@@ -4,8 +4,8 @@ import (
"errors"
"net/http"
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/server/subsonic/engine"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
)

View File

@@ -5,7 +5,7 @@ import (
"errors"
"net/http/httptest"
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/server/subsonic/engine"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)

View File

@@ -9,9 +9,9 @@ import (
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/core"
"github.com/deluan/navidrome/engine"
"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"
"github.com/go-chi/chi"
@@ -27,11 +27,11 @@ type Router struct {
Artwork core.Artwork
ListGenerator engine.ListGenerator
Playlists engine.Playlists
Ratings engine.Ratings
Scrobbler engine.Scrobbler
Search engine.Search
Users engine.Users
Streamer core.MediaStreamer
Archiver core.Archiver
Players engine.Players
DataStore model.DataStore
@@ -39,11 +39,11 @@ type Router struct {
}
func New(browser engine.Browser, artwork core.Artwork, listGenerator engine.ListGenerator, users engine.Users,
playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search,
streamer core.MediaStreamer, players engine.Players, ds model.DataStore) *Router {
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,
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer, Players: players,
DataStore: ds}
Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer, Archiver: archiver,
Players: players, DataStore: ds}
r.mux = r.routes()
return r
}
@@ -82,6 +82,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)
})
r.Group(func(r chi.Router) {
c := initAlbumListController(api)

View File

@@ -6,9 +6,9 @@ import (
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/engine"
"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"
)
@@ -191,6 +191,13 @@ func (c *BrowsingController) GetArtistInfo2(w http.ResponseWriter, r *http.Reque
return response, nil
}
// TODO Integrate with Last.FM
func (c *BrowsingController) GetTopSongs(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
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,

View File

@@ -2,8 +2,10 @@ package engine
import (
"context"
"fmt"
"time"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
)
@@ -40,6 +42,7 @@ type Entry struct {
PlayerName string
AlbumCount int
BookmarkPosition int64
AbsolutePath string
}
type Entries []Entry
@@ -101,7 +104,11 @@ func FromMediaFile(mf *model.MediaFile) Entry {
e.CoverArt = "al-" + mf.AlbumID
}
e.ContentType = mf.ContentType()
e.Path = mf.Path
e.AbsolutePath = mf.Path
// Creates a "pseudo" Path, to avoid sending absolute paths to the client
if mf.Path != "" {
e.Path = fmt.Sprintf("%s/%s/%s.%s", realArtistName(mf), mf.Album, mf.Title, mf.Suffix)
}
e.DiscNumber = mf.DiscNumber
e.Created = mf.CreatedAt
e.AlbumId = mf.AlbumID
@@ -116,6 +123,17 @@ func FromMediaFile(mf *model.MediaFile) Entry {
return e
}
func realArtistName(mf *model.MediaFile) string {
switch {
case mf.Compilation:
return consts.VariousArtists
case mf.AlbumArtist != "":
return mf.AlbumArtist
}
return mf.Artist
}
func FromAlbums(albums model.Albums) Entries {
entries := make(Entries, len(albums))
for i := range albums {

View File

@@ -8,7 +8,6 @@ var Set = wire.NewSet(
NewBrowser,
NewListGenerator,
NewPlaylists,
NewRatings,
NewScrobbler,
NewSearch,
NewNowPlayingRepository,

View File

@@ -7,9 +7,9 @@ import (
"net/http"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/engine"
"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"
)

View File

@@ -5,23 +5,20 @@ import (
"net/http"
"time"
"github.com/deluan/navidrome/engine"
"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 MediaAnnotationController struct {
scrobbler engine.Scrobbler
ratings engine.Ratings
ds model.DataStore
}
func NewMediaAnnotationController(scrobbler engine.Scrobbler, ratings engine.Ratings) *MediaAnnotationController {
return &MediaAnnotationController{
scrobbler: scrobbler,
ratings: ratings,
}
func NewMediaAnnotationController(scrobbler engine.Scrobbler, ds model.DataStore) *MediaAnnotationController {
return &MediaAnnotationController{scrobbler: scrobbler, ds: ds}
}
func (c *MediaAnnotationController) SetRating(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
@@ -35,7 +32,7 @@ func (c *MediaAnnotationController) SetRating(w http.ResponseWriter, r *http.Req
}
log.Debug(r, "Setting rating", "rating", rating, "id", id)
err = c.ratings.SetRating(r.Context(), id, rating)
err = c.setRating(r.Context(), id, rating)
switch {
case err == model.ErrNotFound:
@@ -49,6 +46,17 @@ func (c *MediaAnnotationController) SetRating(w http.ResponseWriter, r *http.Req
return NewResponse(), nil
}
func (c *MediaAnnotationController) setRating(ctx context.Context, id string, rating int) error {
exist, err := c.ds.Album(ctx).Exists(id)
if err != nil {
return err
}
if exist {
return c.ds.Album(ctx).SetRating(rating, id)
}
return c.ds.MediaFile(ctx).SetRating(rating, id)
}
func (c *MediaAnnotationController) Star(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
ids := utils.ParamStrings(r, "id")
albumIds := utils.ParamStrings(r, "albumId")
@@ -67,23 +75,6 @@ func (c *MediaAnnotationController) Star(w http.ResponseWriter, r *http.Request)
return NewResponse(), nil
}
func (c *MediaAnnotationController) setStar(ctx context.Context, starred bool, ids ...string) error {
if len(ids) == 0 {
return nil
}
log.Debug(ctx, "Changing starred", "ids", ids, "starred", starred)
err := c.ratings.SetStar(ctx, starred, ids...)
switch {
case err == model.ErrNotFound:
log.Error(ctx, err)
return NewError(responses.ErrorDataNotFound, "ID not found")
case err != nil:
log.Error(ctx, err)
return NewError(responses.ErrorGeneric, "Internal Error")
}
return nil
}
func (c *MediaAnnotationController) Unstar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
ids := utils.ParamStrings(r, "id")
albumIds := utils.ParamStrings(r, "albumId")
@@ -140,3 +131,56 @@ func (c *MediaAnnotationController) Scrobble(w http.ResponseWriter, r *http.Requ
}
return NewResponse(), nil
}
func (c *MediaAnnotationController) setStar(ctx context.Context, star bool, ids ...string) error {
if len(ids) == 0 {
return nil
}
log.Debug(ctx, "Changing starred", "ids", ids, "starred", star)
if len(ids) == 0 {
log.Warn(ctx, "Cannot star/unstar an empty list of ids")
return nil
}
err := c.ds.WithTx(func(tx model.DataStore) error {
for _, id := range ids {
exist, err := tx.Album(ctx).Exists(id)
if err != nil {
return err
}
if exist {
err = tx.Album(ctx).SetStar(star, ids...)
if err != nil {
return err
}
continue
}
exist, err = tx.Artist(ctx).Exists(id)
if err != nil {
return err
}
if exist {
err = tx.Artist(ctx).SetStar(star, ids...)
if err != nil {
return err
}
continue
}
err = tx.MediaFile(ctx).SetStar(star, ids...)
if err != nil {
return err
}
}
return nil
})
switch {
case err == model.ErrNotFound:
log.Error(ctx, err)
return NewError(responses.ErrorDataNotFound, "ID not found")
case err != nil:
log.Error(ctx, err)
return NewError(responses.ErrorGeneric, "Internal Error")
}
return nil
}

View File

@@ -7,10 +7,10 @@ import (
"net/url"
"strings"
"github.com/deluan/navidrome/engine"
"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"
)

View File

@@ -7,10 +7,10 @@ import (
"net/http/httptest"
"strings"
"github.com/deluan/navidrome/engine"
"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/onsi/ginkgo"
. "github.com/onsi/gomega"
)

View File

@@ -6,9 +6,9 @@ import (
"fmt"
"net/http"
"github.com/deluan/navidrome/engine"
"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"
)

View File

@@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","topSongs":{"song":[{"id":"1","isDir":false,"title":"title","isVideo":false}]}}

View File

@@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><topSongs><song id="1" isDir="false" title="title" isVideo="false"></song></topSongs></subsonic-response>

View File

@@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","topSongs":{}}

View File

@@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><topSongs></topSongs></subsonic-response>

View File

@@ -38,6 +38,7 @@ type Subsonic struct {
ArtistInfo *ArtistInfo `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"`
ArtistInfo2 *ArtistInfo2 `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"`
TopSongs *TopSongs `xml:"topSongs,omitempty" json:"topSongs,omitempty"`
PlayQueue *PlayQueue `xml:"playQueue,omitempty" json:"playQueue,omitempty"`
Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"`
@@ -297,6 +298,10 @@ type ArtistInfo2 struct {
SimilarArtist []ArtistID3 `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}
type TopSongs struct {
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type PlayQueue struct {
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`

View File

@@ -332,6 +332,35 @@ var _ = Describe("Responses", func() {
})
})
Describe("TopSongs", func() {
BeforeEach(func() {
response.TopSongs = &TopSongs{}
})
Context("without data", func() {
It("should match .XML", func() {
Expect(xml.Marshal(response)).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.Marshal(response)).To(MatchSnapshot())
})
})
Context("with data", func() {
BeforeEach(func() {
child := make([]Child, 1)
child[0] = Child{Id: "1", Title: "title", IsDir: false}
response.TopSongs.Song = child
})
It("should match .XML", func() {
Expect(xml.Marshal(response)).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.Marshal(response)).To(MatchSnapshot())
})
})
})
Describe("PlayQueue", func() {
BeforeEach(func() {
response.PlayQueue = &PlayQueue{}

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"net/http"
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/server/subsonic/engine"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
)

View File

@@ -7,16 +7,19 @@ import (
"github.com/deluan/navidrome/core"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
)
type StreamController struct {
streamer core.MediaStreamer
archiver core.Archiver
ds model.DataStore
}
func NewStreamController(streamer core.MediaStreamer) *StreamController {
return &StreamController{streamer: streamer}
func NewStreamController(streamer core.MediaStreamer, archiver core.Archiver, ds model.DataStore) *StreamController {
return &StreamController{streamer: streamer, archiver: archiver, ds: ds}
}
func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
@@ -73,11 +76,26 @@ func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*re
return nil, err
}
stream, err := c.streamer.NewStream(r.Context(), id, "raw", 0)
isTrack, err := c.ds.MediaFile(r.Context()).Exists(id)
if err != nil {
return nil, err
}
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
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")
w.Header().Set("Content-Type", "application/zip")
err := c.archiver.Zip(r.Context(), id, w)
if err != nil {
return nil, err
}
}
return nil, nil
}

View File

@@ -30,8 +30,8 @@ func initAlbumListController(router *Router) *AlbumListController {
func initMediaAnnotationController(router *Router) *MediaAnnotationController {
scrobbler := router.Scrobbler
ratings := router.Ratings
mediaAnnotationController := NewMediaAnnotationController(scrobbler, ratings)
dataStore := router.DataStore
mediaAnnotationController := NewMediaAnnotationController(scrobbler, dataStore)
return mediaAnnotationController
}
@@ -60,7 +60,9 @@ func initMediaRetrievalController(router *Router) *MediaRetrievalController {
func initStreamController(router *Router) *StreamController {
mediaStreamer := router.Streamer
streamController := NewStreamController(mediaStreamer)
archiver := router.Archiver
dataStore := router.DataStore
streamController := NewStreamController(mediaStreamer, archiver, dataStore)
return streamController
}
@@ -82,5 +84,6 @@ var allProviders = wire.NewSet(
NewUsersController,
NewMediaRetrievalController,
NewStreamController,
NewBookmarksController, wire.FieldsOf(new(*Router), "Browser", "Artwork", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer", "DataStore"),
NewBookmarksController, wire.FieldsOf(new(*Router), "Browser", "Artwork", "ListGenerator", "Playlists", "Scrobbler",
"Search", "Streamer", "Archiver", "DataStore"),
)

View File

@@ -17,7 +17,8 @@ var allProviders = wire.NewSet(
NewMediaRetrievalController,
NewStreamController,
NewBookmarksController,
wire.FieldsOf(new(*Router), "Browser", "Artwork", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer", "DataStore"),
wire.FieldsOf(new(*Router), "Browser", "Artwork", "ListGenerator", "Playlists", "Scrobbler",
"Search", "Streamer", "Archiver", "DataStore"),
)
func initSystemController(router *Router) *SystemController {

60
ui/package-lock.json generated
View File

@@ -5,9 +5,9 @@
"requires": true,
"dependencies": {
"@ant-design/css-animation": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@ant-design/css-animation/-/css-animation-1.7.2.tgz",
"integrity": "sha512-bvVOe7A+r7lws58B7r+fgnQDK90cV45AXuvGx6i5CCSX1W/M3AJnHsNggDANBxEtWdNdFWcDd5LorB+RdSIlBw=="
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@ant-design/css-animation/-/css-animation-1.7.3.tgz",
"integrity": "sha512-LrX0OGZtW+W6iLnTAqnTaoIsRelYeuLZWsrmBJFUXDALQphPsN8cE5DCsmoSlL0QYb94BQxINiuS70Ar/8BNgA=="
},
"@babel/code-frame": {
"version": "7.8.3",
@@ -13215,17 +13215,17 @@
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.0.tgz",
"integrity": "sha512-qArkXsjJq7H+T86WrIFV0Fnu/tNOkZ4cgXmjkzAu3b/58D5mFIO8JH/y77t7C9q0OdDRdh9s7Ue5GasYssxtXw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
}
}
},
@@ -13253,17 +13253,17 @@
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.0.tgz",
"integrity": "sha512-qArkXsjJq7H+T86WrIFV0Fnu/tNOkZ4cgXmjkzAu3b/58D5mFIO8JH/y77t7C9q0OdDRdh9s7Ue5GasYssxtXw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
}
}
},
@@ -13286,9 +13286,9 @@
}
},
"rc-trigger": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-4.3.0.tgz",
"integrity": "sha512-jnGNzosXmDdivMBjPCYe/AfOXTpJU2/xQ9XukgoXDQEoZq/9lcI1r7eUIfq70WlWpLxlUEqQktiV3hwyy6Nw9g==",
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-4.3.4.tgz",
"integrity": "sha512-GaRqwJ99RA9qpN3crTndOIfQZG+dgs+l2i4bgB7tl1MBTaNbmJyopi+gyoaHwg2/C6mpvQ2XNrzADEyYEkxqlA==",
"requires": {
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.6",
@@ -13299,24 +13299,24 @@
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.0.tgz",
"integrity": "sha512-qArkXsjJq7H+T86WrIFV0Fnu/tNOkZ4cgXmjkzAu3b/58D5mFIO8JH/y77t7C9q0OdDRdh9s7Ue5GasYssxtXw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
}
}
},
"rc-util": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.0.5.tgz",
"integrity": "sha512-zLIdNm6qz+hQbB5T1fmzHFFgPuRl3uB2eS2iLR/mewUWvgC3l7NzRYRVlHoCEEFVUkKEEsHuJXG1J52FInl5lA==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.0.6.tgz",
"integrity": "sha512-uLGxF9WjbpJSjd6iDnIjl8ZeMUglpcuh1DwO26aaXh++yAmlB6eIAJMUwwJCuqJvo4quCvsDPg1VkqHILc4U0A==",
"requires": {
"react-is": "^16.12.0",
"shallowequal": "^1.1.0"
@@ -13721,9 +13721,9 @@
"integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q=="
},
"react-jinke-music-player": {
"version": "4.16.3",
"resolved": "https://registry.npmjs.org/react-jinke-music-player/-/react-jinke-music-player-4.16.3.tgz",
"integrity": "sha512-YgIvbMzTmkGss4WI9+q7/1yFRvMKW3eEPJiTM8hpkANr4m1Y+/2ZkWo2wi4c97fLd2W6gHZbP6ummdbiqyFcXw==",
"version": "4.16.5",
"resolved": "https://registry.npmjs.org/react-jinke-music-player/-/react-jinke-music-player-4.16.5.tgz",
"integrity": "sha512-h5Kldm38E14bQMw3TCMPZWKFQi7b9DNagb+kXypFeTEYrUp6MSkNzRW48V8XRUcd1M/amsXh5oGyxFS43M+0GA==",
"requires": {
"classnames": "^2.2.6",
"downloadjs": "^1.4.7",

View File

@@ -17,7 +17,7 @@
"react-dom": "^16.13.1",
"react-drag-listview": "^0.1.7",
"react-ga": "^3.1.2",
"react-jinke-music-player": "^4.16.3",
"react-jinke-music-player": "^4.16.5",
"react-measure": "^2.3.0",
"react-redux": "^7.2.0",
"react-scripts": "^3.4.1"

View File

@@ -6,11 +6,14 @@ import {
} from 'react-admin'
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
import ShuffleIcon from '@material-ui/icons/Shuffle'
import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined'
import React from 'react'
import { useDispatch } from 'react-redux'
import { playTracks, shuffleTracks } from '../audioplayer'
import subsonic from '../subsonic'
const AlbumActions = ({
albumId,
className,
ids,
data,
@@ -39,6 +42,14 @@ const AlbumActions = ({
>
<ShuffleIcon />
</Button>
<Button
onClick={() => {
subsonic.download(albumId)
}}
label={translate('resources.album.actions.download')}
>
<CloudDownloadOutlinedIcon />
</Button>
</TopToolbar>
)
}

View File

@@ -26,7 +26,7 @@ const AlbumShow = (props) => {
{...props}
albumId={props.id}
title={<Title subTitle={record.name} />}
actions={<AlbumActions />}
actions={<AlbumActions albumId={props.id} />}
filter={{ album_id: props.id }}
resource={'albumSong'}
exporter={false}

View File

@@ -2,29 +2,9 @@ const ALBUM_MODE_GRID = 'ALBUM_GRID_MODE'
const ALBUM_MODE_LIST = 'ALBUM_LIST_MODE'
const selectViewMode = (mode) => ({ type: mode })
const ALBUM_LIST_ALL = 'ALBUM_LIST_ALL'
const ALBUM_LIST_RANDOM = 'ALBUM_LIST_RANDOM'
const ALBUM_LIST_NEWEST = 'ALBUM_LIST_NEWEST'
const ALBUM_LIST_RECENT = 'ALBUM_LIST_RECENT'
const ALBUM_LIST_STARRED = 'ALBUM_LIST_STARRED'
const albumListParams = {
ALBUM_LIST_ALL: { sort: { field: 'name', order: 'ASC' } },
ALBUM_LIST_RANDOM: { sort: { field: 'random' } },
ALBUM_LIST_NEWEST: { sort: { field: 'created_at', order: 'DESC' } },
ALBUM_LIST_RECENT: {
sort: { field: 'play_date', order: 'DESC' },
filter: { starred: true },
},
}
const selectAlbumList = (mode) => ({ type: mode })
const albumViewReducer = (
previousState = {
mode: ALBUM_MODE_GRID,
list: ALBUM_LIST_ALL,
params: { sort: {}, filter: {} },
},
payload
) => {
@@ -33,26 +13,9 @@ const albumViewReducer = (
case ALBUM_MODE_GRID:
case ALBUM_MODE_LIST:
return { ...previousState, mode: type }
case ALBUM_LIST_ALL:
case ALBUM_LIST_RANDOM:
case ALBUM_LIST_NEWEST:
case ALBUM_LIST_RECENT:
case ALBUM_LIST_STARRED:
return { ...previousState, list: type, params: albumListParams[type] }
default:
return previousState
}
}
export {
ALBUM_MODE_LIST,
ALBUM_MODE_GRID,
ALBUM_LIST_ALL,
ALBUM_LIST_RANDOM,
ALBUM_LIST_NEWEST,
ALBUM_LIST_RECENT,
ALBUM_LIST_STARRED,
albumViewReducer,
selectViewMode,
selectAlbumList,
}
export { ALBUM_MODE_LIST, ALBUM_MODE_GRID, albumViewReducer, selectViewMode }

View File

@@ -1,15 +1,16 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { useDispatch } from 'react-redux'
import IconButton from '@material-ui/core/IconButton'
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 { makeStyles } from '@material-ui/core/styles'
import { useDataProvider, useNotify, useTranslate } from 'react-admin'
import { addTracks, playTracks, shuffleTracks } from '../audioplayer'
import { openAddToPlaylist } from '../dialogs/dialogState'
import StarIcon from '@material-ui/icons/Star'
import PropTypes from 'prop-types'
import subsonic from '../subsonic'
const useStyles = makeStyles({
noWrap: {
@@ -35,19 +36,23 @@ const AlbumContextMenu = ({ record, discNumber, color, visible }) => {
const options = {
play: {
label: 'resources.album.actions.playAll',
action: playTracks,
action: (data, ids) => dispatch(playTracks(data, ids)),
},
addToQueue: {
label: 'resources.album.actions.addToQueue',
action: addTracks,
action: (data, ids) => dispatch(addTracks(data, ids)),
},
shuffle: {
label: 'resources.album.actions.shuffle',
action: shuffleTracks,
action: (data, ids) => dispatch(shuffleTracks(data, ids)),
},
addToPlaylist: {
label: 'resources.song.actions.addToPlaylist',
action: (data, ids) => openAddToPlaylist({ selectedIds: ids }),
label: 'resources.album.actions.addToPlaylist',
action: (data, ids) => dispatch(openAddToPlaylist({ selectedIds: ids })),
},
download: {
label: 'resources.album.actions.download',
action: () => subsonic.download(record.id),
},
}
@@ -83,7 +88,7 @@ const AlbumContextMenu = ({ record, discNumber, color, visible }) => {
})
.then((response) => {
let { data, ids } = extractSongsData(response)
dispatch(options[key].action(data, ids))
options[key].action(data, ids)
})
.catch(() => {
notify('ra.page.error', 'warning')

View File

@@ -24,7 +24,8 @@
"actions": {
"addToQueue": "Play Later",
"addToPlaylist": "Add to Playlist",
"playNow": "Play Now"
"playNow": "Play Now",
"shuffleAll": "Shuffle All"
}
},
"album": {
@@ -45,7 +46,9 @@
"playAll": "Play",
"playNext": "Play Next",
"addToQueue": "Play Later",
"shuffle": "Shuffle"
"shuffle": "Shuffle",
"addToPlaylist": "Add to Playlist",
"download": "Download"
},
"lists": {
"all": "All",

View File

@@ -23,6 +23,7 @@ import {
import { useDispatch } from 'react-redux'
import { setTrack } from '../audioplayer'
import { SongBulkActions } from './SongBulkActions'
import { SongListActions } from './SongListActions'
import { AlbumLinkField } from './AlbumLinkField'
import AddToPlaylistDialog from '../dialogs/AddToPlaylistDialog'
@@ -62,6 +63,7 @@ const SongList = (props) => {
sort={{ field: 'title', order: 'ASC' }}
exporter={false}
bulkActionButtons={<SongBulkActions />}
actions={<SongListActions />}
filters={<SongFilter />}
perPage={isXsmall ? 50 : 15}
>

View File

@@ -0,0 +1,85 @@
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>
)
}
export const SongListActions = ({
currentSort,
className,
resource,
filters,
displayedFilters,
filterValues,
permanentFilter,
exporter,
basePath,
selectedIds,
onUnselectItems,
showFilter,
maxResults,
total,
ids,
...rest
}) => {
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: 'button',
})}
<ShuffleAllButton />
</TopToolbar>
)
}
SongListActions.defaultProps = {
selectedIds: [],
onUnselectItems: () => null,
}

View File

@@ -26,4 +26,6 @@ const url = (command, id, options) => {
const scrobble = (id, submit) =>
fetchUtils.fetchJson(url('scrobble', id, { submission: submit }))
export default { url, scrobble }
const download = (id, submit) => (window.location.href = url('download', id))
export default { url, scrobble, download }