navidrome/core/archiver.go
Deluan Quintão 95f67d2c4e
fix(share): reuse cached transcodes for share streams and zip downloads (#6262)
* fix(share): reuse cached transcodes when streaming from share links

Public share streams built the stream request with only the share's format
and bit rate, leaving sample rate, bit depth and channels at zero. Regular
playback resolves those through the transcode decider (e.g. 48000 Hz for
Opus), and they are part of the transcoding cache key, so a track already
transcoded during normal playback was transcoded again into a separate,
identical cache entry when played through a share link.

The public router now resolves share stream requests with the same
TranscodeDecider.ResolveRequest used by the Subsonic stream endpoint, so
both paths produce the same request and share cache entries.

Fixes #6261

* fix(archiver): reuse cached transcodes when zipping downloads

Zip downloads (album, artist, playlist and share) built the stream request
with only the format and bit rate, leaving sample rate, bit depth and
channels at zero. Those are part of the transcoding cache key, so a track
already transcoded for playback was transcoded again into a separate cache
entry when downloaded in a zip, and vice versa.

The archiver now resolves each request with TranscodeDecider.ResolveRequest,
the same as single-song downloads and streams. This also applies the
decider's defaults, so a zip requested without a bit rate uses the target
format's default bit rate instead of leaving it to ffmpeg.

* fix(archiver): name zip entries after the resolved transcoding format

The transcode decider can pick a different format than the one requested
(for example a player's forced transcoding, or a fallback to the default
downsampling format when the requested one can't be produced). Zip entry
names and the playlist M3U were still built from the requested format, so
an entry could end in .mp3 or .flac while holding Opus data.

Each track's request is now resolved before its entry name is built, and
the name uses the resolved format.
2026-10-03 08:58:54 -07:00

362 lines
12 KiB
Go

package core
import (
"archive/zip"
"cmp"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
const archiveCoverArtSize = 500
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, s *model.Share, w io.Writer) error
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork}
}
type archiver struct {
ds model.DataStore
ms stream.MediaStreamer
decider stream.TranscodeDecider
shares Share
artwork artwork.Artwork
}
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}, model.ArtworkID{})
}
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
// Match by album-artist participation, not the deprecated album_artist_id
// column (first album artist only), so co-album-artists are included too.
filter := squirrel.And{
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
squirrel.Eq{"missing": false},
}
return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID())
}
// rootArt, when set, is added to the archive root.
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error {
mfs, err := a.ds.MediaFile().GetAll(ctx, 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.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID })
folders := albumFolders(albums)
for _, album := range albums {
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
isMultiDisc := len(discs) > 1
folder := folders[album[0].AlbumID]
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
for _, mf := range album {
req := a.resolveRequest(ctx, &mf, format, bitrate)
file := a.albumFilename(mf, req.Format, isMultiDisc, folder)
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
// Stop iterating: continuing would just rack up more
// rejections from the limiter. Close finalises whatever
// tracks were already written; the rejected one is not
// present in the archive (addFileToZip aborts before
// writing its entry header).
_ = z.Close()
return addErr
}
}
// After the tracks, so a slow artwork lookup doesn't delay the first bytes.
a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder)
}
a.addCoverArtToZip(ctx, z, rootArt, "")
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
}
// Tried in order; the first one whose values are distinct across the clashing albums wins.
// One album may have an empty value: it keeps the plain name, which the others can't clash with.
var albumDisambiguators = []func(model.MediaFile) string{
func(mf model.MediaFile) string { return mf.Tags.First(model.TagAlbumVersion) },
func(mf model.MediaFile) string {
// Reissues share Year (often the original's) but not ReleaseYear.
if y := cmp.Or(mf.ReleaseYear, mf.Year); y != 0 {
return strconv.Itoa(y)
}
return ""
},
func(mf model.MediaFile) string { return mf.MbzAlbumType },
func(mf model.MediaFile) string { return mf.Tags.First(model.TagRecordLabel) },
func(mf model.MediaFile) string { return mf.CatalogNum },
func(mf model.MediaFile) string { return mf.AlbumID[:min(6, len(mf.AlbumID))] },
func(mf model.MediaFile) string { return mf.AlbumID },
}
// albumFolders maps each album id to its zip folder. Albums whose names sanitize to the
// same folder get a " [suffix]" from the first disambiguator that tells them all apart.
func albumFolders(albums [][]model.MediaFile) map[string]string {
byName := map[string][]model.MediaFile{}
for _, album := range albums {
name := str.SanitizeFilename(album[0].FullAlbumName())
byName[name] = append(byName[name], album[0])
}
folders := make(map[string]string, len(albums))
for name, group := range byName {
if len(group) == 1 {
folders[group[0].AlbumID] = name
continue
}
fields:
for _, field := range albumDisambiguators {
ids := make(map[string]string, len(group)) // suffix -> album id
for _, mf := range group {
s := str.SanitizeFilename(field(mf))
if _, dup := ids[s]; dup {
continue fields
}
ids[s] = mf.AlbumID
}
for s, id := range ids {
folders[id] = name
if s != "" {
folders[id] = fmt.Sprintf("%s [%s]", name, s)
}
}
break
}
}
return folders
}
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool, folder string) 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", folder, file)
}
// ZipShare takes an already-loaded share: Share.Load records a visit, so
// loading it again here would count every download twice.
func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer) error {
if !s.Downloadable {
return model.ErrNotAuthorized
}
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
// The share is the authorization (as in the public image handler): an anonymous lookup would
// hide a private playlist. Only the cover read is elevated.
coverCtx := request.WithUser(ctx, model.User{IsAdmin: true})
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false)
}
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
pls, err := a.ds.Playlist().GetWithTracks(ctx, 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, ctx, pls.CoverArtID(), true)
}
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, coverCtx context.Context, coverArt model.ArtworkID, addM3U bool) error {
z := createZipWriter(out, format, bitrate)
zippedMfs := make(model.MediaFiles, len(mfs))
for idx, mf := range mfs {
req := a.resolveRequest(ctx, &mf, format, bitrate)
file := a.playlistFilename(mf, req.Format, idx)
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
// Abort the whole archive: continuing would silently emit
// empty zip entries since the headers are already written.
_ = z.Close()
return addErr
}
mf.Path = file
zippedMfs[idx] = mf
}
a.addCoverArtToZip(coverCtx, z, coverArt, "")
// Add M3U file if requested
if addM3U && len(zippedMfs) > 0 {
plsName := str.SanitizeFilename(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, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
}
func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request {
if format == "" || format == "raw" {
return stream.Request{Format: "raw"}
}
return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0)
}
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error {
path := mf.AbsolutePath()
// Open the source before writing the zip entry header so a rejection
// (limiter, missing file, etc.) does not leave an empty entry in the
// archive.
var r io.ReadCloser
var err error
if req.Format != "raw" {
r, err = a.ms.NewStream(ctx, &mf, req)
} else {
r, err = os.Open(path)
}
if err != nil {
log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.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)
}
}()
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
}
_, err = io.Copy(w, r)
if err != nil {
log.Error(ctx, "Error zipping file", "file", path, err)
return err
}
return nil
}
// addCoverArtToZip adds the cover as dir/folder.<ext>. Errors are logged, never returned.
func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) {
if artID.ID == "" {
return
}
// Buffered so a failed read leaves no empty entry.
data, err := a.readCoverArt(ctx, artID)
if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) {
log.Debug(ctx, "No cover art to add to zip", "artID", artID)
return
}
if err != nil {
log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err)
return
}
ext := coverArtExtension(data)
if ext == "" {
log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID)
return
}
w, err := z.CreateHeader(&zip.FileHeader{
Name: path.Join(dir, "folder."+ext),
Modified: time.Now(),
Method: zip.Store,
})
if err != nil {
log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err)
return
}
if _, err = w.Write(data); err != nil {
log.Warn(ctx, "Error zipping cover art", "artID", artID, err)
}
}
func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) {
img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false)
if err != nil {
return nil, err
}
defer img.Close()
return io.ReadAll(img)
}
// Resizing may re-encode the image, so the type comes from its bytes.
func coverArtExtension(data []byte) string {
switch http.DetectContentType(data) {
case "image/jpeg":
return "jpg"
case "image/png":
return "png"
case "image/webp":
return "webp"
case "image/gif":
return "gif"
}
return ""
}