🏡 index : ironmagma/quintodrome.git

package core

import (
	"archive/zip"
	"context"
	"errors"
	"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"
	"github.com/navidrome/navidrome/utils/str"
)

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)
			if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, 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
			}
		}
	}
	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", str.SanitizeFilename(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)
		if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, 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
	}

	// 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) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, 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 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)
		}
	}()

	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
}