🏡 index : ironmagma/quintodrome.git

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
}