From 54395e7e6a03b2b4a9e038fb32519fa4e55ad00f Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 18 Dec 2022 12:12:37 -0500 Subject: [PATCH] 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> --- cmd/wire_gen.go | 2 +- conf/configuration.go | 2 + core/archiver.go | 94 ++++++++++----- core/media_streamer.go | 5 + server/subsonic/stream.go | 89 +++++++++----- ui/src/App.js | 2 + ui/src/actions/dialogs.js | 18 +++ ui/src/album/AlbumActions.js | 7 +- ui/src/album/AlbumList.js | 2 + ui/src/album/AlbumSongs.js | 2 + ui/src/artist/ArtistList.js | 2 + ui/src/common/ContextMenus.js | 15 ++- ui/src/common/SongContextMenu.js | 7 +- ui/src/dialogs/DownloadMenuDialog.js | 173 +++++++++++++++++++++++++++ ui/src/i18n/en.json | 3 +- ui/src/playlist/PlaylistActions.js | 14 ++- ui/src/playlist/PlaylistSongs.js | 2 + ui/src/reducers/dialogReducer.js | 49 ++++++++ ui/src/song/SongList.js | 2 + ui/src/subsonic/index.js | 3 +- 20 files changed, 421 insertions(+), 72 deletions(-) create mode 100644 ui/src/dialogs/DownloadMenuDialog.js diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index def3d830..3e212280 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -50,7 +50,7 @@ func CreateSubsonicAPIRouter() *subsonic.Router { transcoderTranscoder := transcoder.New() transcodingCache := core.GetTranscodingCache() mediaStreamer := core.NewMediaStreamer(dataStore, transcoderTranscoder, transcodingCache) - archiver := core.NewArchiver(dataStore) + archiver := core.NewArchiver(mediaStreamer, dataStore) players := core.NewPlayers(dataStore) agentsAgents := agents.New(dataStore) externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents) diff --git a/conf/configuration.go b/conf/configuration.go index 0690bc46..a25a4fff 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -34,6 +34,7 @@ type configOptions struct { ImageCacheSize string AutoImportPlaylists bool PlaylistsPath string + AutoTranscodeDownload bool SearchFullString bool RecentlyAddedByModTime bool @@ -228,6 +229,7 @@ func init() { viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath) viper.SetDefault("enabledownloads", true) viper.SetDefault("enableexternalservices", true) + viper.SetDefault("autotranscodedownload", false) // Config options only valid for file/env configuration viper.SetDefault("searchfullstring", false) diff --git a/core/archiver.go b/core/archiver.go index 520db273..a6647809 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" @@ -14,22 +15,23 @@ import ( ) type Archiver interface { - ZipAlbum(ctx context.Context, id string, w io.Writer) error - ZipArtist(ctx context.Context, id string, w io.Writer) error - ZipPlaylist(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, format string, bitrate int, w io.Writer) error + ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } -func NewArchiver(ds model.DataStore) Archiver { - return &archiver{ds: ds} +func NewArchiver(ms MediaStreamer, ds model.DataStore) Archiver { + return &archiver{ds: ds, ms: ms} } type archiver struct { 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{ Filters: squirrel.Eq{"album_id": id}, 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) 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{ Sort: "album", 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) 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) if err != nil { log.Error(ctx, "Error loading mediafiles from playlist", "id", id, 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) + 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() if err != nil { 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 } -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) + + if format != "raw" { + file = strings.Replace(file, "."+mf.Suffix, "."+format, 1) + } + return &zip.FileHeader{ Name: fmt.Sprintf("%s/%s", mf.Album, file), 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) + + if format != "raw" { + file = strings.Replace(file, "."+mf.Suffix, "."+format, 1) + } + return &zip.FileHeader{ Name: fmt.Sprintf("%d - %s - %s", idx+1, mf.AlbumArtist, file), 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) 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 + + if format != "raw" { + stream, err := a.ms.DoStream(ctx, &mf, format, bitrate) + + 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 } diff --git a/core/media_streamer.go b/core/media_streamer.go index c35afb73..85038dc9 100644 --- a/core/media_streamer.go +++ b/core/media_streamer.go @@ -20,6 +20,7 @@ import ( type MediaStreamer interface { 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 @@ -51,6 +52,10 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str 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 bitRate int var cached bool diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index 6f3601cf..f83b7a2b 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -1,6 +1,7 @@ package subsonic import ( + "context" "fmt" "io" "net/http" @@ -16,31 +17,7 @@ import ( "github.com/navidrome/navidrome/utils" ) -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") - 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)) - +func (api *Router) ServeStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *core.Stream, id string) { if stream.Seekable() { http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) } 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("Content-Type", stream.ContentType()) + estimateContentLength := utils.ParamBool(r, "estimateContentLength", false) + // if Client requests the estimated content-length, send it if estimateContentLength { 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 } @@ -90,6 +96,27 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. 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) { name = strings.ReplaceAll(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) { case *model.MediaFile: - stream, err := api.streamer.NewStream(ctx, id, "raw", 0) + stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate) + if err != nil { return nil, err } disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name()) w.Header().Set("Content-Disposition", disposition) - http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) + + api.ServeStream(ctx, w, r, stream, id) return nil, nil case *model.Album: setHeaders(v.Name) - err = api.archiver.ZipAlbum(ctx, id, w) + err = api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) case *model.Artist: setHeaders(v.Name) - err = api.archiver.ZipArtist(ctx, id, w) + err = api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) case *model.Playlist: setHeaders(v.Name) - err = api.archiver.ZipPlaylist(ctx, id, w) + err = api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) default: err = model.ErrNotFound } diff --git a/ui/src/App.js b/ui/src/App.js index 5f279101..9281964f 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -25,6 +25,7 @@ import { albumViewReducer, activityReducer, settingsReducer, + downloadMenuDialogReducer, } from './reducers' import createAdminStore from './store/createAdminStore' import { i18nProvider } from './i18n' @@ -52,6 +53,7 @@ const adminStore = createAdminStore({ albumView: albumViewReducer, theme: themeReducer, addToPlaylistDialog: addToPlaylistDialogReducer, + downloadMenuDialog: downloadMenuDialogReducer, expandInfoDialog: expandInfoDialogReducer, listenBrainzTokenDialog: listenBrainzTokenDialogReducer, activity: activityReducer, diff --git a/ui/src/actions/dialogs.js b/ui/src/actions/dialogs.js index 8feb4475..d3764b85 100644 --- a/ui/src/actions/dialogs.js +++ b/ui/src/actions/dialogs.js @@ -1,11 +1,17 @@ export const ADD_TO_PLAYLIST_OPEN = 'ADD_TO_PLAYLIST_OPEN' 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_CLOSE = 'DUPLICATE_SONG_WARNING_CLOSE' export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN' export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE' export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN' 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 }) => ({ type: ADD_TO_PLAYLIST_OPEN, @@ -17,6 +23,18 @@ export const closeAddToPlaylist = () => ({ 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) => ({ type: DUPLICATE_SONG_WARNING_OPEN, duplicateIds, diff --git a/ui/src/album/AlbumActions.js b/ui/src/album/AlbumActions.js index 58dc25b7..060672ea 100644 --- a/ui/src/album/AlbumActions.js +++ b/ui/src/album/AlbumActions.js @@ -18,8 +18,9 @@ import { playTracks, shuffleTracks, openAddToPlaylist, + openDownloadMenu, + DOWNLOAD_MENU_ALBUM, } from '../actions' -import subsonic from '../subsonic' import { formatBytes } from '../utils' import { useMediaQuery, makeStyles } from '@material-ui/core' import config from '../config' @@ -64,8 +65,8 @@ const AlbumActions = ({ }, [dispatch, ids]) const handleDownload = React.useCallback(() => { - subsonic.download(record.id) - }, [record]) + dispatch(openDownloadMenu(record, DOWNLOAD_MENU_ALBUM)) + }, [dispatch, record]) return ( diff --git a/ui/src/album/AlbumList.js b/ui/src/album/AlbumList.js index be9bea98..fe317519 100644 --- a/ui/src/album/AlbumList.js +++ b/ui/src/album/AlbumList.js @@ -28,6 +28,7 @@ import { AddToPlaylistDialog } from '../dialogs' import albumLists, { defaultAlbumList } from './albumLists' import config from '../config' import AlbumInfo from './AlbumInfo' +import DownloadMenuDialog from '../dialogs/DownloadMenuDialog' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' const AlbumFilter = (props) => { @@ -132,6 +133,7 @@ const AlbumList = (props) => { )} + } /> ) diff --git a/ui/src/album/AlbumSongs.js b/ui/src/album/AlbumSongs.js index 9a245327..0e2a57fd 100644 --- a/ui/src/album/AlbumSongs.js +++ b/ui/src/album/AlbumSongs.js @@ -29,6 +29,7 @@ import { } from '../common' import { AddToPlaylistDialog } from '../dialogs' import config from '../config' +import DownloadMenuDialog from '../dialogs/DownloadMenuDialog' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' const useStyles = makeStyles( @@ -187,6 +188,7 @@ const AlbumSongs = (props) => { + } /> ) diff --git a/ui/src/artist/ArtistList.js b/ui/src/artist/ArtistList.js index c64feac2..427d4cbe 100644 --- a/ui/src/artist/ArtistList.js +++ b/ui/src/artist/ArtistList.js @@ -31,6 +31,7 @@ import { import config from '../config' import ArtistListActions from './ArtistListActions' import { DraggableTypes } from '../consts' +import DownloadMenuDialog from '../dialogs/DownloadMenuDialog' const useStyles = makeStyles({ contextHeader: { @@ -173,6 +174,7 @@ const ArtistList = (props) => { + ) } diff --git a/ui/src/common/ContextMenus.js b/ui/src/common/ContextMenus.js index ae113c09..4514e768 100644 --- a/ui/src/common/ContextMenus.js +++ b/ui/src/common/ContextMenus.js @@ -14,9 +14,11 @@ import { playTracks, shuffleTracks, openAddToPlaylist, + openDownloadMenu, openExtendedInfoDialog, + DOWNLOAD_MENU_ALBUM, + DOWNLOAD_MENU_ARTIST, } from '../actions' -import subsonic from '../subsonic' import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' @@ -83,7 +85,16 @@ const ContextMenu = ({ label: `${translate('resources.album.actions.download')} (${formatBytes( record.size )})`, - action: () => subsonic.download(record.id), + action: () => { + dispatch( + openDownloadMenu( + record, + record.duration !== undefined + ? DOWNLOAD_MENU_ALBUM + : DOWNLOAD_MENU_ARTIST + ) + ) + }, }, ...(!hideInfo && { info: { diff --git a/ui/src/common/SongContextMenu.js b/ui/src/common/SongContextMenu.js index 6df06bf0..77671808 100644 --- a/ui/src/common/SongContextMenu.js +++ b/ui/src/common/SongContextMenu.js @@ -12,8 +12,9 @@ import { setTrack, openAddToPlaylist, openExtendedInfoDialog, + openDownloadMenu, + DOWNLOAD_MENU_SONG, } from '../actions' -import subsonic from '../subsonic' import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' @@ -67,7 +68,9 @@ export const SongContextMenu = ({ label: `${translate('resources.song.actions.download')} (${formatBytes( record.size )})`, - action: (record) => subsonic.download(record.mediaFileId || record.id), + action: (record) => { + dispatch(openDownloadMenu(record, DOWNLOAD_MENU_SONG)) + }, }, info: { enabled: true, diff --git a/ui/src/dialogs/DownloadMenuDialog.js b/ui/src/dialogs/DownloadMenuDialog.js new file mode 100644 index 00000000..1b21f75b --- /dev/null +++ b/ui/src/dialogs/DownloadMenuDialog.js @@ -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 ( + <> + props.onChange(e.target.value)} + value={props.value} + > + {Object.values(props.data).map((transcoding) => ( + + {transcoding.name} + + ))} + + + ) +} + +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 ( + <> + + + {record && + `${translate('resources.album.actions.download')} ${type} ${ + record.name || record.title + } (${formatBytes(record.size)})`} + + + +
+ + } + label={translate('message.originalFormat')} + onChange={handleOriginal} + /> + + {!originalFormat && ( + <> + + + + setTargetRate(e.target.value)} + > + - + {[32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320].map( + (bits) => ( + + {bits} + + ) + )} + + + )} +
+
+
+ + + + +
+ + ) +} + +export default DownloadMenuDialog diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index e49f7cb5..95515290 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -322,7 +322,8 @@ "lastfm": "Open in Last.fm", "musicbrainz": "Open in MusicBrainz" }, - "lastfmLink": "Read More..." + "lastfmLink": "Read More...", + "originalFormat": "Download in original format" }, "menu": { "library": "Library", diff --git a/ui/src/playlist/PlaylistActions.js b/ui/src/playlist/PlaylistActions.js index e52d00f3..42ed503f 100644 --- a/ui/src/playlist/PlaylistActions.js +++ b/ui/src/playlist/PlaylistActions.js @@ -14,9 +14,15 @@ import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined' import { RiPlayListAddFill, RiPlayList2Fill } from 'react-icons/ri' import QueueMusicIcon from '@material-ui/icons/QueueMusic' 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 subsonic from '../subsonic' import PropTypes from 'prop-types' import { formatBytes } from '../utils' import { useMediaQuery, makeStyles } from '@material-ui/core' @@ -79,8 +85,8 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => { }, [getAllSongsAndDispatch]) const handleDownload = React.useCallback(() => { - subsonic.download(record.id) - }, [record]) + dispatch(openDownloadMenu(record, DOWNLOAD_MENU_PLAY)) + }, [dispatch, record]) const handleExport = React.useCallback( () => diff --git a/ui/src/playlist/PlaylistSongs.js b/ui/src/playlist/PlaylistSongs.js index 086fb73f..fffc1fcb 100644 --- a/ui/src/playlist/PlaylistSongs.js +++ b/ui/src/playlist/PlaylistSongs.js @@ -31,6 +31,7 @@ import { AddToPlaylistDialog } from '../dialogs' import { AlbumLinkField } from '../song/AlbumLinkField' import { playTracks } from '../actions' import PlaylistSongBulkActions from './PlaylistSongBulkActions' +import DownloadMenuDialog from '../dialogs/DownloadMenuDialog' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' const useStyles = makeStyles( @@ -214,6 +215,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { + } /> {React.cloneElement(props.pagination, listContext)} diff --git a/ui/src/reducers/dialogReducer.js b/ui/src/reducers/dialogReducer.js index ea95248f..0468e32c 100644 --- a/ui/src/reducers/dialogReducer.js +++ b/ui/src/reducers/dialogReducer.js @@ -1,6 +1,12 @@ import { ADD_TO_PLAYLIST_CLOSE, 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_CLOSE, 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 = ( previousState = { open: false, diff --git a/ui/src/song/SongList.js b/ui/src/song/SongList.js index 5307dae8..8fbe7ded 100644 --- a/ui/src/song/SongList.js +++ b/ui/src/song/SongList.js @@ -34,6 +34,7 @@ import { AlbumLinkField } from './AlbumLinkField' import { AddToPlaylistDialog } from '../dialogs' import { SongBulkActions, QualityInfo, useSelectedFields } from '../common' import config from '../config' +import DownloadMenuDialog from '../dialogs/DownloadMenuDialog' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' const useStyles = makeStyles({ @@ -194,6 +195,7 @@ const SongList = (props) => { )} + } /> ) diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 4861c5ec..54b71a01 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -38,7 +38,8 @@ const unstar = (id) => httpClient(url('unstar', id)) 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))