mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-18 21:07:44 +03:00
Enable transcoding of downlods (#1667)
* feat(download): Enable transcoding of downlods - #573 Signed-off-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com> * feat(download): Make automatic transcoding of downloads optional Signed-off-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com> * Fix spelling * address changes * prettier * fix config * use previous name Signed-off-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
This commit is contained in:
parent
6489dd4478
commit
54395e7e6a
@ -50,7 +50,7 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
|||||||
transcoderTranscoder := transcoder.New()
|
transcoderTranscoder := transcoder.New()
|
||||||
transcodingCache := core.GetTranscodingCache()
|
transcodingCache := core.GetTranscodingCache()
|
||||||
mediaStreamer := core.NewMediaStreamer(dataStore, transcoderTranscoder, transcodingCache)
|
mediaStreamer := core.NewMediaStreamer(dataStore, transcoderTranscoder, transcodingCache)
|
||||||
archiver := core.NewArchiver(dataStore)
|
archiver := core.NewArchiver(mediaStreamer, dataStore)
|
||||||
players := core.NewPlayers(dataStore)
|
players := core.NewPlayers(dataStore)
|
||||||
agentsAgents := agents.New(dataStore)
|
agentsAgents := agents.New(dataStore)
|
||||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
||||||
|
@ -34,6 +34,7 @@ type configOptions struct {
|
|||||||
ImageCacheSize string
|
ImageCacheSize string
|
||||||
AutoImportPlaylists bool
|
AutoImportPlaylists bool
|
||||||
PlaylistsPath string
|
PlaylistsPath string
|
||||||
|
AutoTranscodeDownload bool
|
||||||
|
|
||||||
SearchFullString bool
|
SearchFullString bool
|
||||||
RecentlyAddedByModTime bool
|
RecentlyAddedByModTime bool
|
||||||
@ -228,6 +229,7 @@ func init() {
|
|||||||
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
|
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
|
||||||
viper.SetDefault("enabledownloads", true)
|
viper.SetDefault("enabledownloads", true)
|
||||||
viper.SetDefault("enableexternalservices", true)
|
viper.SetDefault("enableexternalservices", true)
|
||||||
|
viper.SetDefault("autotranscodedownload", false)
|
||||||
|
|
||||||
// Config options only valid for file/env configuration
|
// Config options only valid for file/env configuration
|
||||||
viper.SetDefault("searchfullstring", false)
|
viper.SetDefault("searchfullstring", false)
|
||||||
|
@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
@ -14,22 +15,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Archiver interface {
|
type Archiver interface {
|
||||||
ZipAlbum(ctx context.Context, id string, w io.Writer) error
|
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
ZipArtist(ctx context.Context, id string, w io.Writer) error
|
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
ZipPlaylist(ctx context.Context, id string, w io.Writer) error
|
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewArchiver(ds model.DataStore) Archiver {
|
func NewArchiver(ms MediaStreamer, ds model.DataStore) Archiver {
|
||||||
return &archiver{ds: ds}
|
return &archiver{ds: ds, ms: ms}
|
||||||
}
|
}
|
||||||
|
|
||||||
type archiver struct {
|
type archiver struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
|
ms MediaStreamer
|
||||||
}
|
}
|
||||||
|
|
||||||
type createHeader func(idx int, mf model.MediaFile) *zip.FileHeader
|
type createHeader func(idx int, mf model.MediaFile, format string) *zip.FileHeader
|
||||||
|
|
||||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, out io.Writer) error {
|
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"album_id": id},
|
Filters: squirrel.Eq{"album_id": id},
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
@ -38,10 +40,10 @@ func (a *archiver) ZipAlbum(ctx context.Context, id string, out io.Writer) error
|
|||||||
log.Error(ctx, "Error loading mediafiles from album", "id", id, err)
|
log.Error(ctx, "Error loading mediafiles from album", "id", id, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return a.zipTracks(ctx, id, out, mfs, a.createHeader)
|
return a.zipTracks(ctx, id, format, bitrate, out, mfs, a.createHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipArtist(ctx context.Context, id string, out io.Writer) error {
|
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
Filters: squirrel.Eq{"album_artist_id": id},
|
Filters: squirrel.Eq{"album_artist_id": id},
|
||||||
@ -50,23 +52,25 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, out io.Writer) erro
|
|||||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return a.zipTracks(ctx, id, out, mfs, a.createHeader)
|
return a.zipTracks(ctx, id, format, bitrate, out, mfs, a.createHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, out io.Writer) error {
|
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
pls, err := a.ds.Playlist(ctx).GetWithTracks(id)
|
pls, err := a.ds.Playlist(ctx).GetWithTracks(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return a.zipTracks(ctx, id, out, pls.MediaFiles(), a.createPlaylistHeader)
|
return a.zipTracks(ctx, id, format, bitrate, out, pls.MediaFiles(), a.createPlaylistHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) zipTracks(ctx context.Context, id string, out io.Writer, mfs model.MediaFiles, ch createHeader) error {
|
func (a *archiver) zipTracks(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, ch createHeader) error {
|
||||||
z := zip.NewWriter(out)
|
z := zip.NewWriter(out)
|
||||||
|
|
||||||
for idx, mf := range mfs {
|
for idx, mf := range mfs {
|
||||||
_ = a.addFileToZip(ctx, z, mf, ch(idx, mf))
|
_ = a.addFileToZip(ctx, z, mf, format, bitrate, ch(idx, mf, format))
|
||||||
}
|
}
|
||||||
|
|
||||||
err := z.Close()
|
err := z.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||||
@ -74,8 +78,13 @@ func (a *archiver) zipTracks(ctx context.Context, id string, out io.Writer, mfs
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) createHeader(idx int, mf model.MediaFile) *zip.FileHeader {
|
func (a *archiver) createHeader(idx int, mf model.MediaFile, format string) *zip.FileHeader {
|
||||||
_, file := filepath.Split(mf.Path)
|
_, file := filepath.Split(mf.Path)
|
||||||
|
|
||||||
|
if format != "raw" {
|
||||||
|
file = strings.Replace(file, "."+mf.Suffix, "."+format, 1)
|
||||||
|
}
|
||||||
|
|
||||||
return &zip.FileHeader{
|
return &zip.FileHeader{
|
||||||
Name: fmt.Sprintf("%s/%s", mf.Album, file),
|
Name: fmt.Sprintf("%s/%s", mf.Album, file),
|
||||||
Modified: mf.UpdatedAt,
|
Modified: mf.UpdatedAt,
|
||||||
@ -83,8 +92,13 @@ func (a *archiver) createHeader(idx int, mf model.MediaFile) *zip.FileHeader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) createPlaylistHeader(idx int, mf model.MediaFile) *zip.FileHeader {
|
func (a *archiver) createPlaylistHeader(idx int, mf model.MediaFile, format string) *zip.FileHeader {
|
||||||
_, file := filepath.Split(mf.Path)
|
_, file := filepath.Split(mf.Path)
|
||||||
|
|
||||||
|
if format != "raw" {
|
||||||
|
file = strings.Replace(file, "."+mf.Suffix, "."+format, 1)
|
||||||
|
}
|
||||||
|
|
||||||
return &zip.FileHeader{
|
return &zip.FileHeader{
|
||||||
Name: fmt.Sprintf("%d - %s - %s", idx+1, mf.AlbumArtist, file),
|
Name: fmt.Sprintf("%d - %s - %s", idx+1, mf.AlbumArtist, file),
|
||||||
Modified: mf.UpdatedAt,
|
Modified: mf.UpdatedAt,
|
||||||
@ -92,22 +106,46 @@ func (a *archiver) createPlaylistHeader(idx int, mf model.MediaFile) *zip.FileHe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, zh *zip.FileHeader) error {
|
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, zh *zip.FileHeader) error {
|
||||||
w, err := z.CreateHeader(zh)
|
w, err := z.CreateHeader(zh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error creating zip entry", "file", mf.Path, err)
|
log.Error(ctx, "Error creating zip entry", "file", mf.Path, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f, err := os.Open(mf.Path)
|
|
||||||
defer func() { _ = f.Close() }()
|
if format != "raw" {
|
||||||
if err != nil {
|
stream, err := a.ms.DoStream(ctx, &mf, format, bitrate)
|
||||||
log.Error(ctx, "Error opening file for zipping", "file", mf.Path, err)
|
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err := stream.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug {
|
||||||
|
log.Error("Error closing stream", "id", mf.ID, "file", stream.Name(), err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = io.Copy(w, stream)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Error zipping file", "file", mf.Path, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
_, err = io.Copy(w, f)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error zipping file", "file", mf.Path, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
type MediaStreamer interface {
|
type MediaStreamer interface {
|
||||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
|
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
|
||||||
|
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TranscodingCache cache.FileCache
|
type TranscodingCache cache.FileCache
|
||||||
@ -51,6 +52,10 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ms.DoStream(ctx, mf, reqFormat, reqBitRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error) {
|
||||||
var format string
|
var format string
|
||||||
var bitRate int
|
var bitRate int
|
||||||
var cached bool
|
var cached bool
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package subsonic
|
package subsonic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -16,31 +17,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
func (api *Router) ServeStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *core.Stream, id string) {
|
||||||
ctx := r.Context()
|
|
||||||
id, err := requiredParamString(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
maxBitRate := utils.ParamInt(r, "maxBitRate", 0)
|
|
||||||
format := utils.ParamString(r, "format")
|
|
||||||
estimateContentLength := utils.ParamBool(r, "estimateContentLength", false)
|
|
||||||
|
|
||||||
stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure the stream will be closed at the end, to avoid leakage
|
|
||||||
defer func() {
|
|
||||||
if err := stream.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug {
|
|
||||||
log.Error(r.Context(), "Error closing stream", "id", id, "file", stream.Name(), err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
||||||
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
|
|
||||||
|
|
||||||
if stream.Seekable() {
|
if stream.Seekable() {
|
||||||
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
|
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
|
||||||
} else {
|
} else {
|
||||||
@ -48,6 +25,8 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su
|
|||||||
w.Header().Set("Accept-Ranges", "none")
|
w.Header().Set("Accept-Ranges", "none")
|
||||||
w.Header().Set("Content-Type", stream.ContentType())
|
w.Header().Set("Content-Type", stream.ContentType())
|
||||||
|
|
||||||
|
estimateContentLength := utils.ParamBool(r, "estimateContentLength", false)
|
||||||
|
|
||||||
// if Client requests the estimated content-length, send it
|
// if Client requests the estimated content-length, send it
|
||||||
if estimateContentLength {
|
if estimateContentLength {
|
||||||
length := strconv.Itoa(stream.EstimatedContentLength())
|
length := strconv.Itoa(stream.EstimatedContentLength())
|
||||||
@ -68,6 +47,33 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
id, err := requiredParamString(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
maxBitRate := utils.ParamInt(r, "maxBitRate", 0)
|
||||||
|
format := utils.ParamString(r, "format")
|
||||||
|
|
||||||
|
stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the stream will be closed at the end, to avoid leakage
|
||||||
|
defer func() {
|
||||||
|
if err := stream.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug {
|
||||||
|
log.Error("Error closing stream", "id", id, "file", stream.Name(), err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
|
||||||
|
|
||||||
|
api.ServeStream(ctx, w, r, stream, id)
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@ -90,6 +96,27 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxBitRate := utils.ParamInt(r, "bitrate", 0)
|
||||||
|
format := utils.ParamString(r, "format")
|
||||||
|
|
||||||
|
if format == "" {
|
||||||
|
if conf.Server.AutoTranscodeDownload {
|
||||||
|
// if we are not provided a format, see if we have requested transcoding for this client
|
||||||
|
// This must be enabled via a config option. For the UI, we are always given an option.
|
||||||
|
// This will impact other clients which do not use the UI
|
||||||
|
transcoding, ok := request.TranscodingFrom(ctx)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
format = "raw"
|
||||||
|
} else {
|
||||||
|
format = transcoding.TargetFormat
|
||||||
|
maxBitRate = transcoding.DefaultBitRate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format = "raw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setHeaders := func(name string) {
|
setHeaders := func(name string) {
|
||||||
name = strings.ReplaceAll(name, ",", "_")
|
name = strings.ReplaceAll(name, ",", "_")
|
||||||
disposition := fmt.Sprintf("attachment; filename=\"%s.zip\"", name)
|
disposition := fmt.Sprintf("attachment; filename=\"%s.zip\"", name)
|
||||||
@ -99,24 +126,26 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
|
|||||||
|
|
||||||
switch v := entity.(type) {
|
switch v := entity.(type) {
|
||||||
case *model.MediaFile:
|
case *model.MediaFile:
|
||||||
stream, err := api.streamer.NewStream(ctx, id, "raw", 0)
|
stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name())
|
disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name())
|
||||||
w.Header().Set("Content-Disposition", disposition)
|
w.Header().Set("Content-Disposition", disposition)
|
||||||
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
|
|
||||||
|
api.ServeStream(ctx, w, r, stream, id)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
case *model.Album:
|
case *model.Album:
|
||||||
setHeaders(v.Name)
|
setHeaders(v.Name)
|
||||||
err = api.archiver.ZipAlbum(ctx, id, w)
|
err = api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)
|
||||||
case *model.Artist:
|
case *model.Artist:
|
||||||
setHeaders(v.Name)
|
setHeaders(v.Name)
|
||||||
err = api.archiver.ZipArtist(ctx, id, w)
|
err = api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)
|
||||||
case *model.Playlist:
|
case *model.Playlist:
|
||||||
setHeaders(v.Name)
|
setHeaders(v.Name)
|
||||||
err = api.archiver.ZipPlaylist(ctx, id, w)
|
err = api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)
|
||||||
default:
|
default:
|
||||||
err = model.ErrNotFound
|
err = model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,7 @@ import {
|
|||||||
albumViewReducer,
|
albumViewReducer,
|
||||||
activityReducer,
|
activityReducer,
|
||||||
settingsReducer,
|
settingsReducer,
|
||||||
|
downloadMenuDialogReducer,
|
||||||
} from './reducers'
|
} from './reducers'
|
||||||
import createAdminStore from './store/createAdminStore'
|
import createAdminStore from './store/createAdminStore'
|
||||||
import { i18nProvider } from './i18n'
|
import { i18nProvider } from './i18n'
|
||||||
@ -52,6 +53,7 @@ const adminStore = createAdminStore({
|
|||||||
albumView: albumViewReducer,
|
albumView: albumViewReducer,
|
||||||
theme: themeReducer,
|
theme: themeReducer,
|
||||||
addToPlaylistDialog: addToPlaylistDialogReducer,
|
addToPlaylistDialog: addToPlaylistDialogReducer,
|
||||||
|
downloadMenuDialog: downloadMenuDialogReducer,
|
||||||
expandInfoDialog: expandInfoDialogReducer,
|
expandInfoDialog: expandInfoDialogReducer,
|
||||||
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
|
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
|
||||||
activity: activityReducer,
|
activity: activityReducer,
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
export const ADD_TO_PLAYLIST_OPEN = 'ADD_TO_PLAYLIST_OPEN'
|
export const ADD_TO_PLAYLIST_OPEN = 'ADD_TO_PLAYLIST_OPEN'
|
||||||
export const ADD_TO_PLAYLIST_CLOSE = 'ADD_TO_PLAYLIST_CLOSE'
|
export const ADD_TO_PLAYLIST_CLOSE = 'ADD_TO_PLAYLIST_CLOSE'
|
||||||
|
export const DOWNLOAD_MENU_OPEN = 'DOWNLOAD_MENU_OPEN'
|
||||||
|
export const DOWNLOAD_MENU_CLOSE = 'DOWNLOAD_MENU_CLOSE'
|
||||||
export const DUPLICATE_SONG_WARNING_OPEN = 'DUPLICATE_SONG_WARNING_OPEN'
|
export const DUPLICATE_SONG_WARNING_OPEN = 'DUPLICATE_SONG_WARNING_OPEN'
|
||||||
export const DUPLICATE_SONG_WARNING_CLOSE = 'DUPLICATE_SONG_WARNING_CLOSE'
|
export const DUPLICATE_SONG_WARNING_CLOSE = 'DUPLICATE_SONG_WARNING_CLOSE'
|
||||||
export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN'
|
export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN'
|
||||||
export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE'
|
export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE'
|
||||||
export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN'
|
export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN'
|
||||||
export const LISTENBRAINZ_TOKEN_CLOSE = 'LISTENBRAINZ_TOKEN_CLOSE'
|
export const LISTENBRAINZ_TOKEN_CLOSE = 'LISTENBRAINZ_TOKEN_CLOSE'
|
||||||
|
export const DOWNLOAD_MENU_ALBUM = 'album'
|
||||||
|
export const DOWNLOAD_MENU_ARTIST = 'artist'
|
||||||
|
export const DOWNLOAD_MENU_PLAY = 'playlist'
|
||||||
|
export const DOWNLOAD_MENU_SONG = 'song'
|
||||||
|
|
||||||
export const openAddToPlaylist = ({ selectedIds, onSuccess }) => ({
|
export const openAddToPlaylist = ({ selectedIds, onSuccess }) => ({
|
||||||
type: ADD_TO_PLAYLIST_OPEN,
|
type: ADD_TO_PLAYLIST_OPEN,
|
||||||
@ -17,6 +23,18 @@ export const closeAddToPlaylist = () => ({
|
|||||||
type: ADD_TO_PLAYLIST_CLOSE,
|
type: ADD_TO_PLAYLIST_CLOSE,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const openDownloadMenu = (record, recordType) => {
|
||||||
|
return {
|
||||||
|
type: DOWNLOAD_MENU_OPEN,
|
||||||
|
recordType,
|
||||||
|
record,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const closeDownloadMenu = () => ({
|
||||||
|
type: DOWNLOAD_MENU_CLOSE,
|
||||||
|
})
|
||||||
|
|
||||||
export const openDuplicateSongWarning = (duplicateIds) => ({
|
export const openDuplicateSongWarning = (duplicateIds) => ({
|
||||||
type: DUPLICATE_SONG_WARNING_OPEN,
|
type: DUPLICATE_SONG_WARNING_OPEN,
|
||||||
duplicateIds,
|
duplicateIds,
|
||||||
|
@ -18,8 +18,9 @@ import {
|
|||||||
playTracks,
|
playTracks,
|
||||||
shuffleTracks,
|
shuffleTracks,
|
||||||
openAddToPlaylist,
|
openAddToPlaylist,
|
||||||
|
openDownloadMenu,
|
||||||
|
DOWNLOAD_MENU_ALBUM,
|
||||||
} from '../actions'
|
} from '../actions'
|
||||||
import subsonic from '../subsonic'
|
|
||||||
import { formatBytes } from '../utils'
|
import { formatBytes } from '../utils'
|
||||||
import { useMediaQuery, makeStyles } from '@material-ui/core'
|
import { useMediaQuery, makeStyles } from '@material-ui/core'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
@ -64,8 +65,8 @@ const AlbumActions = ({
|
|||||||
}, [dispatch, ids])
|
}, [dispatch, ids])
|
||||||
|
|
||||||
const handleDownload = React.useCallback(() => {
|
const handleDownload = React.useCallback(() => {
|
||||||
subsonic.download(record.id)
|
dispatch(openDownloadMenu(record, DOWNLOAD_MENU_ALBUM))
|
||||||
}, [record])
|
}, [dispatch, record])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
||||||
|
@ -28,6 +28,7 @@ import { AddToPlaylistDialog } from '../dialogs'
|
|||||||
import albumLists, { defaultAlbumList } from './albumLists'
|
import albumLists, { defaultAlbumList } from './albumLists'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import AlbumInfo from './AlbumInfo'
|
import AlbumInfo from './AlbumInfo'
|
||||||
|
import DownloadMenuDialog from '../dialogs/DownloadMenuDialog'
|
||||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||||
|
|
||||||
const AlbumFilter = (props) => {
|
const AlbumFilter = (props) => {
|
||||||
@ -132,6 +133,7 @@ const AlbumList = (props) => {
|
|||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
<AddToPlaylistDialog />
|
<AddToPlaylistDialog />
|
||||||
|
<DownloadMenuDialog />
|
||||||
<ExpandInfoDialog content={<AlbumInfo />} />
|
<ExpandInfoDialog content={<AlbumInfo />} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -29,6 +29,7 @@ import {
|
|||||||
} from '../common'
|
} from '../common'
|
||||||
import { AddToPlaylistDialog } from '../dialogs'
|
import { AddToPlaylistDialog } from '../dialogs'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
|
import DownloadMenuDialog from '../dialogs/DownloadMenuDialog'
|
||||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
@ -187,6 +188,7 @@ const AlbumSongs = (props) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<AddToPlaylistDialog />
|
<AddToPlaylistDialog />
|
||||||
|
<DownloadMenuDialog />
|
||||||
<ExpandInfoDialog content={<SongInfo />} />
|
<ExpandInfoDialog content={<SongInfo />} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -31,6 +31,7 @@ import {
|
|||||||
import config from '../config'
|
import config from '../config'
|
||||||
import ArtistListActions from './ArtistListActions'
|
import ArtistListActions from './ArtistListActions'
|
||||||
import { DraggableTypes } from '../consts'
|
import { DraggableTypes } from '../consts'
|
||||||
|
import DownloadMenuDialog from '../dialogs/DownloadMenuDialog'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
contextHeader: {
|
contextHeader: {
|
||||||
@ -173,6 +174,7 @@ const ArtistList = (props) => {
|
|||||||
<ArtistListView {...props} />
|
<ArtistListView {...props} />
|
||||||
</List>
|
</List>
|
||||||
<AddToPlaylistDialog />
|
<AddToPlaylistDialog />
|
||||||
|
<DownloadMenuDialog />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -14,9 +14,11 @@ import {
|
|||||||
playTracks,
|
playTracks,
|
||||||
shuffleTracks,
|
shuffleTracks,
|
||||||
openAddToPlaylist,
|
openAddToPlaylist,
|
||||||
|
openDownloadMenu,
|
||||||
openExtendedInfoDialog,
|
openExtendedInfoDialog,
|
||||||
|
DOWNLOAD_MENU_ALBUM,
|
||||||
|
DOWNLOAD_MENU_ARTIST,
|
||||||
} from '../actions'
|
} from '../actions'
|
||||||
import subsonic from '../subsonic'
|
|
||||||
import { LoveButton } from './LoveButton'
|
import { LoveButton } from './LoveButton'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { formatBytes } from '../utils'
|
import { formatBytes } from '../utils'
|
||||||
@ -83,7 +85,16 @@ const ContextMenu = ({
|
|||||||
label: `${translate('resources.album.actions.download')} (${formatBytes(
|
label: `${translate('resources.album.actions.download')} (${formatBytes(
|
||||||
record.size
|
record.size
|
||||||
)})`,
|
)})`,
|
||||||
action: () => subsonic.download(record.id),
|
action: () => {
|
||||||
|
dispatch(
|
||||||
|
openDownloadMenu(
|
||||||
|
record,
|
||||||
|
record.duration !== undefined
|
||||||
|
? DOWNLOAD_MENU_ALBUM
|
||||||
|
: DOWNLOAD_MENU_ARTIST
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
...(!hideInfo && {
|
...(!hideInfo && {
|
||||||
info: {
|
info: {
|
||||||
|
@ -12,8 +12,9 @@ import {
|
|||||||
setTrack,
|
setTrack,
|
||||||
openAddToPlaylist,
|
openAddToPlaylist,
|
||||||
openExtendedInfoDialog,
|
openExtendedInfoDialog,
|
||||||
|
openDownloadMenu,
|
||||||
|
DOWNLOAD_MENU_SONG,
|
||||||
} from '../actions'
|
} from '../actions'
|
||||||
import subsonic from '../subsonic'
|
|
||||||
import { LoveButton } from './LoveButton'
|
import { LoveButton } from './LoveButton'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { formatBytes } from '../utils'
|
import { formatBytes } from '../utils'
|
||||||
@ -67,7 +68,9 @@ export const SongContextMenu = ({
|
|||||||
label: `${translate('resources.song.actions.download')} (${formatBytes(
|
label: `${translate('resources.song.actions.download')} (${formatBytes(
|
||||||
record.size
|
record.size
|
||||||
)})`,
|
)})`,
|
||||||
action: (record) => subsonic.download(record.mediaFileId || record.id),
|
action: (record) => {
|
||||||
|
dispatch(openDownloadMenu(record, DOWNLOAD_MENU_SONG))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
173
ui/src/dialogs/DownloadMenuDialog.js
Normal file
173
ui/src/dialogs/DownloadMenuDialog.js
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
import { ReferenceManyField, useTranslate } from 'react-admin'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
|
FormGroup,
|
||||||
|
MenuItem,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
} from '@material-ui/core'
|
||||||
|
import subsonic from '../subsonic'
|
||||||
|
import { closeDownloadMenu } from '../actions'
|
||||||
|
import { formatBytes } from '../utils'
|
||||||
|
|
||||||
|
const DownloadTranscodings = (props) => {
|
||||||
|
const translate = useTranslate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
id="downloadFormat"
|
||||||
|
select
|
||||||
|
label={translate('resources.transcoding.fields.targetFormat')}
|
||||||
|
onChange={(e) => props.onChange(e.target.value)}
|
||||||
|
value={props.value}
|
||||||
|
>
|
||||||
|
{Object.values(props.data).map((transcoding) => (
|
||||||
|
<MenuItem key={transcoding.id} value={transcoding.targetFormat}>
|
||||||
|
{transcoding.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DownloadMenuDialog = () => {
|
||||||
|
const { open, record, recordType } = useSelector(
|
||||||
|
(state) => state.downloadMenuDialog
|
||||||
|
)
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const translate = useTranslate()
|
||||||
|
|
||||||
|
const [originalFormat, setUseOriginalFormat] = useState(true)
|
||||||
|
const [targetFormat, setTargetFormat] = useState('')
|
||||||
|
const [targetRate, setTargetRate] = useState(0)
|
||||||
|
|
||||||
|
const handleClose = (e) => {
|
||||||
|
dispatch(closeDownloadMenu())
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = (e) => {
|
||||||
|
if (record) {
|
||||||
|
subsonic.download(
|
||||||
|
record.id,
|
||||||
|
originalFormat ? 'raw' : targetFormat,
|
||||||
|
targetRate
|
||||||
|
)
|
||||||
|
dispatch(closeDownloadMenu())
|
||||||
|
}
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOriginal = (e) => {
|
||||||
|
const original = e.target.checked
|
||||||
|
|
||||||
|
setUseOriginalFormat(original)
|
||||||
|
|
||||||
|
if (original) {
|
||||||
|
setTargetFormat('')
|
||||||
|
setTargetRate(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = recordType
|
||||||
|
? translate(`resources.${recordType}.name`, {
|
||||||
|
smart_count: 1,
|
||||||
|
}).toLocaleLowerCase()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
onBackdropClick={handleClose}
|
||||||
|
aria-labelledby="download-dialog"
|
||||||
|
fullWidth={true}
|
||||||
|
maxWidth={'sm'}
|
||||||
|
>
|
||||||
|
<DialogTitle id="download-dialog">
|
||||||
|
{record &&
|
||||||
|
`${translate('resources.album.actions.download')} ${type} ${
|
||||||
|
record.name || record.title
|
||||||
|
} (${formatBytes(record.size)})`}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{
|
||||||
|
'& .MuiTextField-root': { m: 1, width: '25ch' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={originalFormat} />}
|
||||||
|
label={translate('message.originalFormat')}
|
||||||
|
onChange={handleOriginal}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{!originalFormat && (
|
||||||
|
<>
|
||||||
|
<ReferenceManyField
|
||||||
|
fullWidth
|
||||||
|
source=""
|
||||||
|
target="name"
|
||||||
|
reference="transcoding"
|
||||||
|
sort={{ field: 'name', order: 'ASC' }}
|
||||||
|
>
|
||||||
|
<DownloadTranscodings
|
||||||
|
onChange={setTargetFormat}
|
||||||
|
value={targetFormat}
|
||||||
|
/>
|
||||||
|
</ReferenceManyField>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
id="downloadRate"
|
||||||
|
select
|
||||||
|
label={translate('resources.player.fields.maxBitRate')}
|
||||||
|
value={targetRate}
|
||||||
|
onChange={(e) => setTargetRate(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value={0}>-</MenuItem>
|
||||||
|
{[32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320].map(
|
||||||
|
(bits) => (
|
||||||
|
<MenuItem key={bits} value={bits}>
|
||||||
|
{bits}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</TextField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
onClick={handleDownload}
|
||||||
|
color="primary"
|
||||||
|
disabled={!originalFormat && !targetFormat}
|
||||||
|
>
|
||||||
|
{translate('resources.album.actions.download')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleClose} color="secondary">
|
||||||
|
{translate('ra.action.close')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DownloadMenuDialog
|
@ -322,7 +322,8 @@
|
|||||||
"lastfm": "Open in Last.fm",
|
"lastfm": "Open in Last.fm",
|
||||||
"musicbrainz": "Open in MusicBrainz"
|
"musicbrainz": "Open in MusicBrainz"
|
||||||
},
|
},
|
||||||
"lastfmLink": "Read More..."
|
"lastfmLink": "Read More...",
|
||||||
|
"originalFormat": "Download in original format"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
|
@ -14,9 +14,15 @@ import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined'
|
|||||||
import { RiPlayListAddFill, RiPlayList2Fill } from 'react-icons/ri'
|
import { RiPlayListAddFill, RiPlayList2Fill } from 'react-icons/ri'
|
||||||
import QueueMusicIcon from '@material-ui/icons/QueueMusic'
|
import QueueMusicIcon from '@material-ui/icons/QueueMusic'
|
||||||
import { httpClient } from '../dataProvider'
|
import { httpClient } from '../dataProvider'
|
||||||
import { playNext, addTracks, playTracks, shuffleTracks } from '../actions'
|
import {
|
||||||
|
playNext,
|
||||||
|
addTracks,
|
||||||
|
playTracks,
|
||||||
|
shuffleTracks,
|
||||||
|
openDownloadMenu,
|
||||||
|
DOWNLOAD_MENU_PLAY,
|
||||||
|
} from '../actions'
|
||||||
import { M3U_MIME_TYPE, REST_URL } from '../consts'
|
import { M3U_MIME_TYPE, REST_URL } from '../consts'
|
||||||
import subsonic from '../subsonic'
|
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { formatBytes } from '../utils'
|
import { formatBytes } from '../utils'
|
||||||
import { useMediaQuery, makeStyles } from '@material-ui/core'
|
import { useMediaQuery, makeStyles } from '@material-ui/core'
|
||||||
@ -79,8 +85,8 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => {
|
|||||||
}, [getAllSongsAndDispatch])
|
}, [getAllSongsAndDispatch])
|
||||||
|
|
||||||
const handleDownload = React.useCallback(() => {
|
const handleDownload = React.useCallback(() => {
|
||||||
subsonic.download(record.id)
|
dispatch(openDownloadMenu(record, DOWNLOAD_MENU_PLAY))
|
||||||
}, [record])
|
}, [dispatch, record])
|
||||||
|
|
||||||
const handleExport = React.useCallback(
|
const handleExport = React.useCallback(
|
||||||
() =>
|
() =>
|
||||||
|
@ -31,6 +31,7 @@ import { AddToPlaylistDialog } from '../dialogs'
|
|||||||
import { AlbumLinkField } from '../song/AlbumLinkField'
|
import { AlbumLinkField } from '../song/AlbumLinkField'
|
||||||
import { playTracks } from '../actions'
|
import { playTracks } from '../actions'
|
||||||
import PlaylistSongBulkActions from './PlaylistSongBulkActions'
|
import PlaylistSongBulkActions from './PlaylistSongBulkActions'
|
||||||
|
import DownloadMenuDialog from '../dialogs/DownloadMenuDialog'
|
||||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
@ -214,6 +215,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<AddToPlaylistDialog />
|
<AddToPlaylistDialog />
|
||||||
|
<DownloadMenuDialog />
|
||||||
<ExpandInfoDialog content={<SongInfo />} />
|
<ExpandInfoDialog content={<SongInfo />} />
|
||||||
{React.cloneElement(props.pagination, listContext)}
|
{React.cloneElement(props.pagination, listContext)}
|
||||||
</>
|
</>
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
ADD_TO_PLAYLIST_CLOSE,
|
ADD_TO_PLAYLIST_CLOSE,
|
||||||
ADD_TO_PLAYLIST_OPEN,
|
ADD_TO_PLAYLIST_OPEN,
|
||||||
|
DOWNLOAD_MENU_ALBUM,
|
||||||
|
DOWNLOAD_MENU_ARTIST,
|
||||||
|
DOWNLOAD_MENU_CLOSE,
|
||||||
|
DOWNLOAD_MENU_OPEN,
|
||||||
|
DOWNLOAD_MENU_PLAY,
|
||||||
|
DOWNLOAD_MENU_SONG,
|
||||||
DUPLICATE_SONG_WARNING_OPEN,
|
DUPLICATE_SONG_WARNING_OPEN,
|
||||||
DUPLICATE_SONG_WARNING_CLOSE,
|
DUPLICATE_SONG_WARNING_CLOSE,
|
||||||
EXTENDED_INFO_OPEN,
|
EXTENDED_INFO_OPEN,
|
||||||
@ -40,6 +46,49 @@ export const addToPlaylistDialogReducer = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const downloadMenuDialogReducer = (
|
||||||
|
previousState = {
|
||||||
|
open: false,
|
||||||
|
},
|
||||||
|
payload
|
||||||
|
) => {
|
||||||
|
const { type } = payload
|
||||||
|
switch (type) {
|
||||||
|
case DOWNLOAD_MENU_OPEN: {
|
||||||
|
switch (payload.recordType) {
|
||||||
|
case DOWNLOAD_MENU_ALBUM:
|
||||||
|
case DOWNLOAD_MENU_ARTIST:
|
||||||
|
case DOWNLOAD_MENU_PLAY:
|
||||||
|
case DOWNLOAD_MENU_SONG: {
|
||||||
|
return {
|
||||||
|
...previousState,
|
||||||
|
open: true,
|
||||||
|
record: payload.record,
|
||||||
|
recordType: payload.recordType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return {
|
||||||
|
...previousState,
|
||||||
|
open: true,
|
||||||
|
record: payload.record,
|
||||||
|
recordType: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case DOWNLOAD_MENU_CLOSE: {
|
||||||
|
return {
|
||||||
|
...previousState,
|
||||||
|
open: false,
|
||||||
|
recordType: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return previousState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const expandInfoDialogReducer = (
|
export const expandInfoDialogReducer = (
|
||||||
previousState = {
|
previousState = {
|
||||||
open: false,
|
open: false,
|
||||||
|
@ -34,6 +34,7 @@ import { AlbumLinkField } from './AlbumLinkField'
|
|||||||
import { AddToPlaylistDialog } from '../dialogs'
|
import { AddToPlaylistDialog } from '../dialogs'
|
||||||
import { SongBulkActions, QualityInfo, useSelectedFields } from '../common'
|
import { SongBulkActions, QualityInfo, useSelectedFields } from '../common'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
|
import DownloadMenuDialog from '../dialogs/DownloadMenuDialog'
|
||||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
@ -194,6 +195,7 @@ const SongList = (props) => {
|
|||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
<AddToPlaylistDialog />
|
<AddToPlaylistDialog />
|
||||||
|
<DownloadMenuDialog />
|
||||||
<ExpandInfoDialog content={<SongInfo />} />
|
<ExpandInfoDialog content={<SongInfo />} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -38,7 +38,8 @@ const unstar = (id) => httpClient(url('unstar', id))
|
|||||||
|
|
||||||
const setRating = (id, rating) => httpClient(url('setRating', id, { rating }))
|
const setRating = (id, rating) => httpClient(url('setRating', id, { rating }))
|
||||||
|
|
||||||
const download = (id) => (window.location.href = baseUrl(url('download', id)))
|
const download = (id, format = 'raw', bitrate = '0') =>
|
||||||
|
(window.location.href = baseUrl(url('download', id, { format, bitrate })))
|
||||||
|
|
||||||
const startScan = (options) => httpClient(url('startScan', null, options))
|
const startScan = (options) => httpClient(url('startScan', null, options))
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user