767744a301
* refactor: rename core/transcode directory to core/stream * refactor: update all imports from core/transcode to core/stream * refactor: rename exported symbols to fit core/stream package name * refactor: simplify MediaStreamer interface to single NewStream method Remove the two-method interface (NewStream + DoStream) in favor of a single NewStream(ctx, mf, req) method. Callers are now responsible for fetching the MediaFile before calling NewStream. This removes the implicit DB lookup from the streamer, making it a pure streaming concern. * refactor: update all callers from DoStream to NewStream * chore: update wire_gen.go and stale comment for core/stream rename * refactor: update wire command to handle GO_BUILD_TAGS correctly Signed-off-by: Deluan <deluan@navidrome.org> * fix: distinguish not-found from internal errors in public stream handler * refactor: remove unused ID field from stream.Request * refactor: simplify ResolveRequestFromToken to receive *model.MediaFile Move MediaFile fetching responsibility to callers, making the method focused on token validation and request resolution. Remove ErrMediaNotFound (no longer produced). Update GetTranscodeStream handler to fetch the media file before calling ResolveRequestFromToken. * refactor: extend tokenTTL from 12 to 48 hours Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
203 lines
6.1 KiB
Go
203 lines
6.1 KiB
Go
package core
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
type Archiver interface {
|
|
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
|
|
ZipShare(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(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
|
return &archiver{ds: ds, ms: ms, shares: shares}
|
|
}
|
|
|
|
type archiver struct {
|
|
ds model.DataStore
|
|
ms stream.MediaStreamer
|
|
shares Share
|
|
}
|
|
|
|
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
|
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id})
|
|
}
|
|
|
|
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
|
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id})
|
|
}
|
|
|
|
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {
|
|
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"})
|
|
if err != nil {
|
|
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
|
return err
|
|
}
|
|
|
|
z := createZipWriter(out, format, bitrate)
|
|
albums := slice.Group(mfs, func(mf model.MediaFile) string {
|
|
return mf.AlbumID
|
|
})
|
|
for _, album := range albums {
|
|
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
|
isMultiDisc := len(discs) > 1
|
|
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist,
|
|
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
|
for _, mf := range album {
|
|
file := a.albumFilename(mf, format, isMultiDisc)
|
|
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
|
}
|
|
}
|
|
err = z.Close()
|
|
if err != nil {
|
|
log.Error(ctx, "Error closing zip file", "id", id, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
|
|
z := zip.NewWriter(out)
|
|
comment := "Downloaded from Navidrome"
|
|
if format != "raw" && format != "" {
|
|
comment = fmt.Sprintf("%s, transcoded to %s %dbps", comment, format, bitrate)
|
|
}
|
|
_ = z.SetComment(comment)
|
|
return z
|
|
}
|
|
|
|
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string {
|
|
_, file := filepath.Split(mf.Path)
|
|
if format != "raw" {
|
|
file = strings.TrimSuffix(file, mf.Suffix) + format
|
|
}
|
|
if isMultiDisc {
|
|
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
|
}
|
|
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
|
|
}
|
|
|
|
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
|
|
s, err := a.shares.Load(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !s.Downloadable {
|
|
return model.ErrNotAuthorized
|
|
}
|
|
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
|
return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
|
}
|
|
|
|
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, true, false)
|
|
if err != nil {
|
|
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
|
return err
|
|
}
|
|
mfs := pls.MediaFiles()
|
|
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
|
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
|
|
}
|
|
|
|
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
|
|
z := createZipWriter(out, format, bitrate)
|
|
|
|
zippedMfs := make(model.MediaFiles, len(mfs))
|
|
for idx, mf := range mfs {
|
|
file := a.playlistFilename(mf, format, idx)
|
|
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
|
mf.Path = file
|
|
zippedMfs[idx] = mf
|
|
}
|
|
|
|
// Add M3U file if requested
|
|
if addM3U && len(zippedMfs) > 0 {
|
|
plsName := sanitizeName(name)
|
|
w, err := z.CreateHeader(&zip.FileHeader{
|
|
Name: plsName + ".m3u",
|
|
Modified: mfs[0].UpdatedAt,
|
|
Method: zip.Store,
|
|
})
|
|
if err != nil {
|
|
log.Error(ctx, "Error creating playlist zip entry", err)
|
|
return err
|
|
}
|
|
|
|
_, err = w.Write([]byte(zippedMfs.ToM3U8(plsName, false)))
|
|
if err != nil {
|
|
log.Error(ctx, "Error writing m3u in zip", err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
err := z.Close()
|
|
if err != nil {
|
|
log.Error(ctx, "Error closing zip file", "id", id, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) string {
|
|
ext := mf.Suffix
|
|
if format != "" && format != "raw" {
|
|
ext = format
|
|
}
|
|
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
|
|
}
|
|
|
|
func sanitizeName(target string) string {
|
|
return strings.ReplaceAll(target, "/", "_")
|
|
}
|
|
|
|
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
|
path := mf.AbsolutePath()
|
|
w, err := z.CreateHeader(&zip.FileHeader{
|
|
Name: filename,
|
|
Modified: mf.UpdatedAt,
|
|
Method: zip.Store,
|
|
})
|
|
if err != nil {
|
|
log.Error(ctx, "Error creating zip entry", "file", path, err)
|
|
return err
|
|
}
|
|
|
|
var r io.ReadCloser
|
|
if format != "raw" && format != "" {
|
|
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
|
} else {
|
|
r, err = os.Open(path)
|
|
}
|
|
if err != nil {
|
|
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
|
return err
|
|
}
|
|
|
|
defer func() {
|
|
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
|
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
|
}
|
|
}()
|
|
|
|
_, err = io.Copy(w, r)
|
|
if err != nil {
|
|
log.Error(ctx, "Error zipping file", "file", path, err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|