mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0730c667a2 | ||
|
|
4ec451aecb | ||
|
|
883dd7f728 | ||
|
|
38c19eddc3 | ||
|
|
8e4b2e1c06 | ||
|
|
a541afbfba | ||
|
|
df05760769 | ||
|
|
9a1133601a | ||
|
|
2c370cae28 | ||
|
|
f745b8d223 | ||
|
|
f1b6703ab0 | ||
|
|
28d1428c90 | ||
|
|
696a0feb31 | ||
|
|
f29e1eb248 | ||
|
|
d4e599233e | ||
|
|
aaec8e080b | ||
|
|
09442eccd4 |
1
.github/workflows/pipeline.yml
vendored
1
.github/workflows/pipeline.yml
vendored
@@ -19,6 +19,7 @@ jobs:
|
||||
with:
|
||||
version: v1.27
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --timeout 2m
|
||||
|
||||
go:
|
||||
name: Test Server on ${{ matrix.os }}
|
||||
|
||||
@@ -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/),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
93
core/archiver.go
Normal 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
|
||||
}
|
||||
@@ -10,5 +10,6 @@ var Set = wire.NewSet(
|
||||
NewMediaStreamer,
|
||||
NewTranscodingCache,
|
||||
NewImageCache,
|
||||
NewArchiver,
|
||||
transcoder.New,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
@@ -8,7 +8,6 @@ var Set = wire.NewSet(
|
||||
NewBrowser,
|
||||
NewListGenerator,
|
||||
NewPlaylists,
|
||||
NewRatings,
|
||||
NewScrobbler,
|
||||
NewSearch,
|
||||
NewNowPlayingRepository,
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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}]}}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","topSongs":{}}
|
||||
@@ -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>
|
||||
@@ -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"`
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
60
ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
85
ui/src/song/SongListActions.js
Normal file
85
ui/src/song/SongListActions.js
Normal 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,
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user