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:
Kendall Garner 2022-12-18 12:12:37 -05:00 committed by GitHub
parent 6489dd4478
commit 54395e7e6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 421 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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