🏡 index : quine-global/quintodrome.git

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