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>
309 lines
8.6 KiB
Go
309 lines
8.6 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/core"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/utils/ioutils"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/navidrome/navidrome/utils/str"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
playlistID string
|
|
outputFile string
|
|
userID string
|
|
outputFormat string
|
|
syncFlag bool
|
|
)
|
|
|
|
type displayPlaylist struct {
|
|
Id string `json:"id"`
|
|
Name string `json:"name"`
|
|
OwnerName string `json:"ownerName"`
|
|
OwnerId string `json:"ownerId"`
|
|
Public bool `json:"public"`
|
|
}
|
|
|
|
type displayPlaylists []displayPlaylist
|
|
|
|
func init() {
|
|
plsCmd.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
|
|
plsCmd.Flags().StringVarP(&outputFile, "output", "o", "", "output file (default stdout)")
|
|
_ = plsCmd.MarkFlagRequired("playlist")
|
|
rootCmd.AddCommand(plsCmd)
|
|
|
|
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
|
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
|
plsCmd.AddCommand(listCommand)
|
|
|
|
exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
|
|
exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory")
|
|
exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
|
plsCmd.AddCommand(exportCommand)
|
|
|
|
importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)")
|
|
importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced")
|
|
plsCmd.AddCommand(importCommand)
|
|
}
|
|
|
|
var (
|
|
plsCmd = &cobra.Command{
|
|
Use: "pls",
|
|
Short: "Export playlists",
|
|
Long: "Export Navidrome playlists to M3U files",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runExporter(cmd.Context())
|
|
},
|
|
}
|
|
|
|
listCommand = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List playlists",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runList(cmd.Context())
|
|
},
|
|
}
|
|
|
|
exportCommand = &cobra.Command{
|
|
Use: "export",
|
|
Short: "Export playlists to M3U files",
|
|
Long: "Export one or more Navidrome playlists to M3U files",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runExport(cmd.Context())
|
|
},
|
|
}
|
|
|
|
importCommand = &cobra.Command{
|
|
Use: "import [files...]",
|
|
Short: "Import M3U playlists",
|
|
Long: "Import one or more M3U files as Navidrome playlists",
|
|
Args: cobra.MinimumNArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runImport(cmd.Context(), args)
|
|
},
|
|
}
|
|
)
|
|
|
|
func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists {
|
|
options := model.QueryOptions{Sort: sort}
|
|
if userID != "" {
|
|
user, err := getUser(ctx, userID, ds)
|
|
if err != nil {
|
|
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
|
|
}
|
|
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
|
}
|
|
pls, err := ds.Playlist(ctx).GetAll(options)
|
|
if err != nil {
|
|
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
|
}
|
|
return pls
|
|
}
|
|
|
|
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
|
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
|
}
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
|
if err != nil {
|
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
|
}
|
|
if len(playlists) > 0 {
|
|
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
|
if err != nil {
|
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
|
}
|
|
}
|
|
}
|
|
if playlist == nil {
|
|
log.Fatal("Playlist not found", "name", nameOrID)
|
|
}
|
|
return playlist
|
|
}
|
|
|
|
func runExporter(ctx context.Context) {
|
|
ds, ctx := getAdminContext(ctx)
|
|
playlist := findPlaylist(ctx, ds, playlistID)
|
|
pls := playlist.ToM3U8()
|
|
if outputFile == "-" || outputFile == "" {
|
|
println(pls)
|
|
return
|
|
}
|
|
err := os.WriteFile(outputFile, []byte(pls), 0600)
|
|
if err != nil {
|
|
log.Fatal("Error writing to the output file", "file", outputFile, err)
|
|
}
|
|
}
|
|
|
|
func runExport(ctx context.Context) {
|
|
ds, ctx := getAdminContext(ctx)
|
|
|
|
if playlistID != "" && outputFile == "" {
|
|
playlist := findPlaylist(ctx, ds, playlistID)
|
|
println(playlist.ToM3U8())
|
|
return
|
|
}
|
|
|
|
if outputFile == "" {
|
|
log.Fatal("Output directory (-o) is required for bulk export or when filtering by user")
|
|
}
|
|
|
|
info, err := os.Stat(outputFile)
|
|
if err != nil || !info.IsDir() {
|
|
log.Fatal("Output path must be an existing directory", "path", outputFile)
|
|
}
|
|
|
|
if playlistID != "" {
|
|
pls := findPlaylist(ctx, ds, playlistID)
|
|
filename := str.SanitizeFilename(pls.Name) + ".m3u"
|
|
path := filepath.Join(outputFile, filename)
|
|
err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600)
|
|
if err != nil {
|
|
log.Fatal("Error writing playlist", "file", path, err)
|
|
}
|
|
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
|
|
return
|
|
}
|
|
|
|
allPls := fetchPlaylists(ctx, ds, "name")
|
|
|
|
nameCounts := make(map[string]int)
|
|
for _, pls := range allPls {
|
|
nameCounts[str.SanitizeFilename(pls.Name)]++
|
|
}
|
|
|
|
exported := 0
|
|
for _, pls := range allPls {
|
|
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
|
|
if err != nil {
|
|
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
|
continue
|
|
}
|
|
|
|
sanitized := str.SanitizeFilename(pls.Name)
|
|
filename := sanitized + ".m3u"
|
|
if nameCounts[sanitized] > 1 {
|
|
shortID := pls.ID
|
|
if len(shortID) > 6 {
|
|
shortID = shortID[:6]
|
|
}
|
|
filename = sanitized + "_" + shortID + ".m3u"
|
|
}
|
|
|
|
path := filepath.Join(outputFile, filename)
|
|
err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600)
|
|
if err != nil {
|
|
log.Error("Error writing playlist", "file", path, err)
|
|
continue
|
|
}
|
|
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
|
|
exported++
|
|
}
|
|
fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile)
|
|
}
|
|
|
|
func runList(ctx context.Context) {
|
|
if outputFormat != "csv" && outputFormat != "json" {
|
|
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
|
|
}
|
|
|
|
ds, ctx := getAdminContext(ctx)
|
|
allPls := fetchPlaylists(ctx, ds, "owner_name")
|
|
|
|
if outputFormat == "csv" {
|
|
w := csv.NewWriter(os.Stdout)
|
|
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
|
|
for _, playlist := range allPls {
|
|
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
|
|
}
|
|
w.Flush()
|
|
} else {
|
|
display := make(displayPlaylists, len(allPls))
|
|
for idx, playlist := range allPls {
|
|
display[idx].Id = playlist.ID
|
|
display[idx].Name = playlist.Name
|
|
display[idx].OwnerId = playlist.OwnerID
|
|
display[idx].OwnerName = playlist.OwnerName
|
|
display[idx].Public = playlist.Public
|
|
}
|
|
|
|
j, _ := json.Marshal(display)
|
|
fmt.Printf("%s\n", j)
|
|
}
|
|
}
|
|
|
|
func runImport(ctx context.Context, files []string) {
|
|
ds, ctx := getAdminContext(ctx)
|
|
|
|
if userID != "" {
|
|
user, err := getUser(ctx, userID, ds)
|
|
if err != nil {
|
|
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
|
|
}
|
|
ctx = request.WithUser(ctx, *user)
|
|
}
|
|
|
|
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
|
|
|
|
for _, file := range files {
|
|
absPath, err := filepath.Abs(file)
|
|
if err != nil {
|
|
log.Error("Error resolving path", "file", file, err)
|
|
fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err)
|
|
continue
|
|
}
|
|
|
|
totalLines := countM3UTrackLines(absPath)
|
|
|
|
imported, err := pls.ImportFile(ctx, absPath, syncFlag)
|
|
if err != nil {
|
|
log.Error("Error importing playlist", "file", absPath, err)
|
|
fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err)
|
|
continue
|
|
}
|
|
|
|
matched := len(imported.Tracks)
|
|
if totalLines > 0 {
|
|
notFound := totalLines - matched
|
|
fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound)
|
|
} else {
|
|
fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched)
|
|
}
|
|
}
|
|
}
|
|
|
|
func countM3UTrackLines(path string) int {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer file.Close()
|
|
|
|
count := 0
|
|
reader := ioutils.UTF8Reader(file)
|
|
for line := range slice.LinesFrom(reader) {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
count++
|
|
}
|
|
return count
|
|
}
|