mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-26 10:30:46 +00:00
Some checks are pending
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
POEditor export / push-translations (push) Waiting to run
* refactor: rename ImportFile to ImportFromFolder in playlists service * feat: add ImportFile method with library/folder resolution * feat: allow sync flag upgrade on re-import of non-synced playlists * feat: add pls export subcommand with bulk and single export Add `navidrome pls export` command that supports: - Single playlist export to stdout (-p flag only) - Single playlist export to directory (-p and -o flags) - Bulk export of all playlists to a directory (-o flag only) - Filtering by user (-u flag) - Automatic filename sanitization and collision detection Also extracts findPlaylist helper from runExporter for reuse. * feat: add pls import subcommand with sync flag support * fix: improve error message for export without output directory * test: add tests for ImportFile sync flag and sync upgrade behavior * refactor: streamline export and import logic by removing redundant comments and improving library path matching Signed-off-by: Deluan <deluan@navidrome.org> * feat: update ImportFile method to include sync flag for playlist imports Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement fetchPlaylists function to streamline playlist retrieval Signed-off-by: Deluan <deluan@navidrome.org> * feat: replace inline filename sanitization with centralized utility function Signed-off-by: Deluan <deluan@navidrome.org> * feat: refactor playlist import logic to consolidate sync handling and improve method signatures Signed-off-by: Deluan <deluan@navidrome.org> * fix: address code review feedback on playlist import/export - Fix duplicate playlist creation on non-sync re-import: only reconcile sync flag when the playlist was actually persisted (has an ID) - Distinguish "not in any library" from real errors in resolveFolder using a sentinel error, so DB/folder errors propagate instead of falling back to ImportM3U - Use bufio.Scanner in countM3UTrackLines instead of reading entire file * feat: replace bufio.Scanner with UTF8Reader and LinesFrom utility for improved file reading Signed-off-by: Deluan <deluan@navidrome.org> * fix: record path for outside-library imports to prevent duplicates Files outside all libraries now go through updatePlaylist with the absolute path recorded, so re-importing the same file updates the existing playlist instead of creating a duplicate. * refactor: name guard condition in updatePlaylist for readability Extracted the compound boolean expression into a named local variable `alreadyImportedAndNotSynced` to make the intent of the early-return guard clearer at a glance. * add godocs Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
199 lines
6.1 KiB
Go
199 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"
|
|
"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)
|
|
_ = 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", 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)
|
|
_ = 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 := 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()
|
|
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
|
|
}
|