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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>

View File

@ -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) => {
)}
</List>
<AddToPlaylistDialog />
<DownloadMenuDialog />
<ExpandInfoDialog content={<AlbumInfo />} />
</>
)

View File

@ -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) => {
</Card>
</div>
<AddToPlaylistDialog />
<DownloadMenuDialog />
<ExpandInfoDialog content={<SongInfo />} />
</>
)

View File

@ -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) => {
<ArtistListView {...props} />
</List>
<AddToPlaylistDialog />
<DownloadMenuDialog />
</>
)
}

View File

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

View File

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

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",
"musicbrainz": "Open in MusicBrainz"
},
"lastfmLink": "Read More..."
"lastfmLink": "Read More...",
"originalFormat": "Download in original format"
},
"menu": {
"library": "Library",

View File

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

View File

@ -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 }) => {
</Card>
</div>
<AddToPlaylistDialog />
<DownloadMenuDialog />
<ExpandInfoDialog content={<SongInfo />} />
{React.cloneElement(props.pagination, listContext)}
</>

View File

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

View File

@ -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) => {
)}
</List>
<AddToPlaylistDialog />
<DownloadMenuDialog />
<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 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))