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.. 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 "" }