Skip to content

Commit

Permalink
Enable transcoding of downlods (navidrome#1667)
Browse files Browse the repository at this point in the history
* feat(download): Enable transcoding of downlods - navidrome#573

Signed-off-by: Kendall Garner <[email protected]>

* feat(download): Make automatic transcoding of downloads optional

Signed-off-by: Kendall Garner <[email protected]>

* Fix spelling

* address changes

* prettier

* fix config

* use previous name

Signed-off-by: Kendall Garner <[email protected]>
  • Loading branch information
kgarner7 authored Dec 18, 2022
1 parent 6489dd4 commit 54395e7
Show file tree
Hide file tree
Showing 20 changed files with 421 additions and 72 deletions.
2 changes: 1 addition & 1 deletion cmd/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type configOptions struct {
ImageCacheSize string
AutoImportPlaylists bool
PlaylistsPath string
AutoTranscodeDownload bool

SearchFullString bool
RecentlyAddedByModTime bool
Expand Down Expand Up @@ -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)
Expand Down
94 changes: 66 additions & 28 deletions core/archiver.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,31 @@ import (
"io"
"os"
"path/filepath"
"strings"

"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)

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",
Expand All @@ -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},
Expand All @@ -50,64 +52,100 @@ 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)
}
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,
Method: zip.Store,
}
}

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,
Method: zip.Store,
}
}

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
}
_, err = io.Copy(w, f)
if err != nil {
log.Error(ctx, "Error zipping file", "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
}
return nil
}
5 changes: 5 additions & 0 deletions core/media_streamer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
89 changes: 59 additions & 30 deletions server/subsonic/stream.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package subsonic

import (
"context"
"fmt"
"io"
"net/http"
Expand All @@ -16,38 +17,16 @@ 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 {
// If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length
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())
Expand All @@ -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
}
Expand All @@ -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)
Expand All @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions ui/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
albumViewReducer,
activityReducer,
settingsReducer,
downloadMenuDialogReducer,
} from './reducers'
import createAdminStore from './store/createAdminStore'
import { i18nProvider } from './i18n'
Expand Down Expand Up @@ -52,6 +53,7 @@ const adminStore = createAdminStore({
albumView: albumViewReducer,
theme: themeReducer,
addToPlaylistDialog: addToPlaylistDialogReducer,
downloadMenuDialog: downloadMenuDialogReducer,
expandInfoDialog: expandInfoDialogReducer,
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
activity: activityReducer,
Expand Down
Loading

0 comments on commit 54395e7

Please sign in to comment.