playlists now save to files, can be restored from files
This commit is contained in:
+7
-1
@@ -96,7 +96,9 @@ func NewYellowJacketApp(
|
|||||||
yjApp.assetHandler.RegisterHandler("/covers/", coverHandler)
|
yjApp.assetHandler.RegisterHandler("/covers/", coverHandler)
|
||||||
|
|
||||||
// create playlist service
|
// create playlist service
|
||||||
yjApp.playlist = playlist.NewService(yjApp.logger, yjApp.database)
|
yjApp.playlist = playlist.NewService(
|
||||||
|
yjApp.logger, yjApp.database, yjApp.appConfig,
|
||||||
|
)
|
||||||
|
|
||||||
yjApp.FEBindings = []any{
|
yjApp.FEBindings = []any{
|
||||||
yjApp.FrontendUtil,
|
yjApp.FrontendUtil,
|
||||||
@@ -146,6 +148,10 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
// clear the queue and stop playback before wiping data.
|
// clear the queue and stop playback before wiping data.
|
||||||
yj.library.SetQueue(yj.queue)
|
yj.library.SetQueue(yj.queue)
|
||||||
|
|
||||||
|
// Give the library a reference to the playlist service so
|
||||||
|
// FullRescan can restore playlists from M3U8 files.
|
||||||
|
yj.library.SetPlaylistRestorer(yj.playlist)
|
||||||
|
|
||||||
// Register playback finished handler to drive queue auto-advance.
|
// Register playback finished handler to drive queue auto-advance.
|
||||||
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ const (
|
|||||||
LibraryConfigChanged = "LibraryConfigChanged"
|
LibraryConfigChanged = "LibraryConfigChanged"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Playlist events.
|
||||||
|
const (
|
||||||
|
PlaylistCreated = "PlaylistCreated"
|
||||||
|
PlaylistDeleted = "PlaylistDeleted"
|
||||||
|
PlaylistRenamed = "PlaylistRenamed"
|
||||||
|
PlaylistTracksChanged = "PlaylistTracksChanged"
|
||||||
|
PlaylistsRestored = "PlaylistsRestored"
|
||||||
|
)
|
||||||
|
|
||||||
// Library events.
|
// Library events.
|
||||||
const (
|
const (
|
||||||
LibraryScanStarted = "LibraryScanStarted"
|
LibraryScanStarted = "LibraryScanStarted"
|
||||||
|
|||||||
@@ -31,8 +31,39 @@ func (fe *FrontendUtil) DirectoryPicker() (string, error) {
|
|||||||
fe.ctx,
|
fe.ctx,
|
||||||
runtime.OpenDialogOptions{})
|
runtime.OpenDialogOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not open directory dialog\n%w", err)
|
return "", fmt.Errorf(
|
||||||
|
"could not open directory dialog\n%w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlaylistFilePicker opens a file selection dialog filtered
|
||||||
|
// to M3U/M3U8 playlist files.
|
||||||
|
func (fe *FrontendUtil) PlaylistFilePicker() (
|
||||||
|
string,
|
||||||
|
error,
|
||||||
|
) {
|
||||||
|
runtime.LogInfo(fe.ctx, "selecting a playlist file")
|
||||||
|
|
||||||
|
file, err := runtime.OpenFileDialog(
|
||||||
|
fe.ctx,
|
||||||
|
runtime.OpenDialogOptions{
|
||||||
|
Title: "Import Playlist",
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "Playlist Files (*.m3u, *.m3u8)",
|
||||||
|
Pattern: "*.m3u;*.m3u8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"could not open file dialog: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,13 +63,20 @@ type queueClearer interface {
|
|||||||
Clear()
|
Clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// playlistRestorer is a narrow interface for restoring playlists
|
||||||
|
// from M3U8 files after a library rescan.
|
||||||
|
type playlistRestorer interface {
|
||||||
|
RestoreAllPlaylists()
|
||||||
|
}
|
||||||
|
|
||||||
// Library manages scanning and querying the music collection.
|
// Library manages scanning and querying the music collection.
|
||||||
type Library struct {
|
type Library struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
conf *Config
|
conf *Config
|
||||||
db *database.DB
|
db *database.DB
|
||||||
queue queueClearer
|
queue queueClearer
|
||||||
|
playlistRestorer playlistRestorer
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetQueue provides the library with a reference to the queue so
|
// SetQueue provides the library with a reference to the queue so
|
||||||
@@ -79,6 +86,13 @@ func (l *Library) SetQueue(q queueClearer) {
|
|||||||
l.queue = q
|
l.queue = q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPlaylistRestorer provides the library with a reference to
|
||||||
|
// the playlist service so that FullRescan can restore playlists
|
||||||
|
// from M3U8 files after wiping data.
|
||||||
|
func (l *Library) SetPlaylistRestorer(p playlistRestorer) {
|
||||||
|
l.playlistRestorer = p
|
||||||
|
}
|
||||||
|
|
||||||
// NewLibrary creates a new library with the given configuration.
|
// NewLibrary creates a new library with the given configuration.
|
||||||
// A nil config is permitted; the library will be inert until a valid
|
// A nil config is permitted; the library will be inert until a valid
|
||||||
// configuration is supplied via the LibraryConfigChanged event.
|
// configuration is supplied via the LibraryConfigChanged event.
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ func (l *Library) FullRescan() (*ScanMetrics, error) {
|
|||||||
clearDBDur + clearFilesDur
|
clearDBDur + clearFilesDur
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore playlists from M3U8 files now that the library
|
||||||
|
// has been rescanned and audio_files are populated again.
|
||||||
|
if l.playlistRestorer != nil {
|
||||||
|
l.playlistRestorer.RestoreAllPlaylists()
|
||||||
|
}
|
||||||
|
|
||||||
return metrics, err
|
return metrics, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
package playlist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
m3uHeader = "#EXTM3U"
|
||||||
|
m3uPlaylist = "#PLAYLIST:"
|
||||||
|
m3uExtInf = "#EXTINF:"
|
||||||
|
m3uExtension = ".m3u8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidM3U = errors.New("invalid M3U file: missing #EXTM3U header")
|
||||||
|
errEmptyM3UFile = errors.New("M3U file is empty")
|
||||||
|
errPlaylistDirNil = errors.New("playlists directory path is empty")
|
||||||
|
)
|
||||||
|
|
||||||
|
// unsafeChars matches characters that are not safe for filenames.
|
||||||
|
// Uses Unicode letter/digit classes so accented characters are kept.
|
||||||
|
var unsafeChars = regexp.MustCompile(`[^\p{L}\p{N}\-. ]+`)
|
||||||
|
|
||||||
|
// m3uEntry represents a single track entry parsed from an M3U8 file.
|
||||||
|
type m3uEntry struct {
|
||||||
|
// RelativePath is the path relative to the library root.
|
||||||
|
RelativePath string
|
||||||
|
// DurationSec is the track duration in seconds (from #EXTINF).
|
||||||
|
DurationSec int
|
||||||
|
// DisplayTitle is the display title (from #EXTINF).
|
||||||
|
DisplayTitle string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsedPlaylist is the result of parsing an M3U8 file.
|
||||||
|
type parsedPlaylist struct {
|
||||||
|
Name string
|
||||||
|
Entries []m3uEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeM3U8 writes a playlist to an M3U8 file at the given directory.
|
||||||
|
// The file is named "{id}-{sanitized-name}.m3u8".
|
||||||
|
func writeM3U8(
|
||||||
|
dirPath string,
|
||||||
|
playlistID int64,
|
||||||
|
name string,
|
||||||
|
entries []m3uEntry,
|
||||||
|
) error {
|
||||||
|
if dirPath == "" {
|
||||||
|
return errPlaylistDirNil
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := playlistFilePath(dirPath, playlistID, name)
|
||||||
|
|
||||||
|
// Remove any old file for this ID with a different name.
|
||||||
|
if err := removeOldPlaylistFile(
|
||||||
|
dirPath, playlistID, filePath,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not remove old playlist file: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not create M3U8 file %q: %w",
|
||||||
|
filePath, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
w := bufio.NewWriter(file)
|
||||||
|
|
||||||
|
// Write header.
|
||||||
|
_, _ = fmt.Fprintln(w, m3uHeader)
|
||||||
|
_, _ = fmt.Fprintf(
|
||||||
|
w, "%s%s\n", m3uPlaylist, name,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Write entries.
|
||||||
|
for _, entry := range entries {
|
||||||
|
_, _ = fmt.Fprintf(
|
||||||
|
w, "%s%d,%s\n",
|
||||||
|
m3uExtInf,
|
||||||
|
entry.DurationSec,
|
||||||
|
entry.DisplayTitle,
|
||||||
|
)
|
||||||
|
_, _ = fmt.Fprintln(w, entry.RelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not flush M3U8 file %q: %w",
|
||||||
|
filePath, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseM3U8 reads and parses an M3U8 (or M3U) file.
|
||||||
|
func parseM3U8(filePath string) (parsedPlaylist, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return parsedPlaylist{}, fmt.Errorf(
|
||||||
|
"could not open M3U file %q: %w", filePath, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
|
||||||
|
var result parsedPlaylist
|
||||||
|
|
||||||
|
headerSeen := false
|
||||||
|
pendingDuration := 0
|
||||||
|
pendingTitle := ""
|
||||||
|
hasPendingExtInf := false
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check header.
|
||||||
|
if !headerSeen {
|
||||||
|
if line == m3uHeader {
|
||||||
|
headerSeen = true
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedPlaylist{}, errInvalidM3U
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist name directive.
|
||||||
|
if strings.HasPrefix(line, m3uPlaylist) {
|
||||||
|
result.Name = strings.TrimPrefix(line, m3uPlaylist)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXTINF line.
|
||||||
|
if strings.HasPrefix(line, m3uExtInf) {
|
||||||
|
dur, title := parseExtInf(line)
|
||||||
|
pendingDuration = dur
|
||||||
|
pendingTitle = title
|
||||||
|
hasPendingExtInf = true
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip other comment lines.
|
||||||
|
if strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a track path line.
|
||||||
|
entry := m3uEntry{
|
||||||
|
RelativePath: line,
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasPendingExtInf {
|
||||||
|
entry.DurationSec = pendingDuration
|
||||||
|
entry.DisplayTitle = pendingTitle
|
||||||
|
hasPendingExtInf = false
|
||||||
|
pendingDuration = 0
|
||||||
|
pendingTitle = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Entries = append(result.Entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return parsedPlaylist{}, fmt.Errorf(
|
||||||
|
"error reading M3U file %q: %w", filePath, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !headerSeen {
|
||||||
|
return parsedPlaylist{}, errEmptyM3UFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive name from filename if not set via #PLAYLIST directive.
|
||||||
|
if result.Name == "" {
|
||||||
|
base := filepath.Base(filePath)
|
||||||
|
result.Name = strings.TrimSuffix(
|
||||||
|
base, filepath.Ext(base),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Strip ID prefix if present (e.g., "1-my-playlist").
|
||||||
|
if idx := strings.Index(result.Name, "-"); idx > 0 {
|
||||||
|
prefix := result.Name[:idx]
|
||||||
|
if _, err := strconv.ParseInt(
|
||||||
|
prefix, 10, 64,
|
||||||
|
); err == nil {
|
||||||
|
result.Name = result.Name[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseExtInf parses an #EXTINF line and returns duration and title.
|
||||||
|
// Format: #EXTINF:duration,display title.
|
||||||
|
func parseExtInf(line string) (int, string) {
|
||||||
|
data := strings.TrimPrefix(line, m3uExtInf)
|
||||||
|
|
||||||
|
commaIdx := strings.Index(data, ",")
|
||||||
|
if commaIdx < 0 {
|
||||||
|
dur, _ := strconv.Atoi(strings.TrimSpace(data))
|
||||||
|
|
||||||
|
return dur, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
durStr := strings.TrimSpace(data[:commaIdx])
|
||||||
|
title := strings.TrimSpace(data[commaIdx+1:])
|
||||||
|
|
||||||
|
dur, _ := strconv.Atoi(durStr)
|
||||||
|
|
||||||
|
return dur, title
|
||||||
|
}
|
||||||
|
|
||||||
|
// playlistFilePath returns the full path for a playlist M3U8 file.
|
||||||
|
func playlistFilePath(
|
||||||
|
dirPath string,
|
||||||
|
id int64,
|
||||||
|
name string,
|
||||||
|
) string {
|
||||||
|
sanitized := sanitizeFilename(name)
|
||||||
|
|
||||||
|
return filepath.Join(
|
||||||
|
dirPath,
|
||||||
|
fmt.Sprintf("%d-%s%s", id, sanitized, m3uExtension),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeFilename converts a playlist name to a safe filename.
|
||||||
|
func sanitizeFilename(name string) string {
|
||||||
|
// Lowercase.
|
||||||
|
s := strings.ToLower(name)
|
||||||
|
|
||||||
|
// Replace spaces and underscores with hyphens.
|
||||||
|
s = strings.ReplaceAll(s, " ", "-")
|
||||||
|
s = strings.ReplaceAll(s, "_", "-")
|
||||||
|
|
||||||
|
// Remove unsafe characters.
|
||||||
|
s = unsafeChars.ReplaceAllString(s, "")
|
||||||
|
|
||||||
|
// Collapse multiple hyphens.
|
||||||
|
for strings.Contains(s, "--") {
|
||||||
|
s = strings.ReplaceAll(s, "--", "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim leading/trailing hyphens and dots.
|
||||||
|
s = strings.Trim(s, "-.")
|
||||||
|
|
||||||
|
// Ensure non-empty.
|
||||||
|
if s == "" {
|
||||||
|
s = "playlist"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate to a reasonable length.
|
||||||
|
const maxLen = 100
|
||||||
|
|
||||||
|
if runeCount := len([]rune(s)); runeCount > maxLen {
|
||||||
|
runes := []rune(s)
|
||||||
|
s = string(runes[:maxLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPlaylistFile finds the existing M3U8 file for a given playlist
|
||||||
|
// ID by globbing for "{id}-*.m3u8".
|
||||||
|
func findPlaylistFile(
|
||||||
|
dirPath string,
|
||||||
|
id int64,
|
||||||
|
) (string, error) {
|
||||||
|
pattern := filepath.Join(
|
||||||
|
dirPath,
|
||||||
|
fmt.Sprintf("%d-*%s", id, m3uExtension),
|
||||||
|
)
|
||||||
|
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"could not glob for playlist file: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeOldPlaylistFile removes an old playlist file for the given
|
||||||
|
// ID if it exists and differs from the expected path.
|
||||||
|
func removeOldPlaylistFile(
|
||||||
|
dirPath string,
|
||||||
|
id int64,
|
||||||
|
expectedPath string,
|
||||||
|
) error {
|
||||||
|
existing, err := findPlaylistFile(dirPath, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing == "" || existing == expectedPath {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(existing); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not remove old playlist file %q: %w",
|
||||||
|
existing, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toAbsolutePath converts a relative path to an absolute path using
|
||||||
|
// the library root. If the path is already absolute, it is returned
|
||||||
|
// as-is.
|
||||||
|
func toAbsolutePath(relativePath, libraryRoot string) string {
|
||||||
|
if filepath.IsAbs(relativePath) {
|
||||||
|
return relativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(libraryRoot, relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toRelativePath converts an absolute path to a relative path based
|
||||||
|
// on the library root. If the path cannot be made relative, it is
|
||||||
|
// returned as-is.
|
||||||
|
func toRelativePath(absolutePath, libraryRoot string) string {
|
||||||
|
if libraryRoot == "" {
|
||||||
|
return absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(libraryRoot, absolutePath)
|
||||||
|
if err != nil {
|
||||||
|
return absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the relative path escapes the library root (starts with
|
||||||
|
// ".."), keep the absolute path.
|
||||||
|
if strings.HasPrefix(rel, "..") {
|
||||||
|
return absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
return rel
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidM3UExtension checks whether a file extension is a
|
||||||
|
// recognized M3U variant.
|
||||||
|
func isValidM3UExtension(ext string) bool {
|
||||||
|
lower := strings.ToLower(ext)
|
||||||
|
|
||||||
|
return lower == ".m3u" || lower == ".m3u8"
|
||||||
|
}
|
||||||
|
|
||||||
|
// listPlaylistFiles returns all M3U8 files in the playlists
|
||||||
|
// directory.
|
||||||
|
func listPlaylistFiles(dirPath string) ([]string, error) {
|
||||||
|
pattern := filepath.Join(dirPath, "*"+m3uExtension)
|
||||||
|
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"could not list playlist files: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPlaylistID extracts the playlist DB ID from an M3U8
|
||||||
|
// filename. The expected format is "{id}-{name}.m3u8". Returns 0 if
|
||||||
|
// the ID cannot be extracted.
|
||||||
|
func extractPlaylistID(filePath string) int64 {
|
||||||
|
base := filepath.Base(filePath)
|
||||||
|
name := strings.TrimSuffix(base, filepath.Ext(base))
|
||||||
|
|
||||||
|
idx := strings.Index(name, "-")
|
||||||
|
if idx <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseInt(name[:idx], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayTitle builds an EXTINF display title from artist and title.
|
||||||
|
func displayTitle(artist, title string) string {
|
||||||
|
artist = strings.TrimSpace(artist)
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
|
||||||
|
if artist == "" && title == "" {
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
if artist == "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
if title == "" {
|
||||||
|
return artist
|
||||||
|
}
|
||||||
|
|
||||||
|
return artist + " - " + title
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
package playlist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeFilename(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple name",
|
||||||
|
input: "My Playlist",
|
||||||
|
expected: "my-playlist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "special characters",
|
||||||
|
input: "Rock & Roll: Best Of!",
|
||||||
|
expected: "rock-roll-best-of",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unicode characters",
|
||||||
|
input: "Música Favorita",
|
||||||
|
expected: "música-favorita",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
expected: "playlist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only special characters",
|
||||||
|
input: "!!!@@@###",
|
||||||
|
expected: "playlist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "underscores become hyphens",
|
||||||
|
input: "my_cool_playlist",
|
||||||
|
expected: "my-cool-playlist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple spaces collapse",
|
||||||
|
input: "my big playlist",
|
||||||
|
expected: "my-big-playlist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading and trailing hyphens trimmed",
|
||||||
|
input: " --hello-- ",
|
||||||
|
expected: "hello",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result := sanitizeFilename(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf(
|
||||||
|
"sanitizeFilename(%q) = %q, want %q",
|
||||||
|
tt.input, result, tt.expected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaylistFilePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result := playlistFilePath("/data/playlists", 42, "My Favorites")
|
||||||
|
expected := filepath.Join(
|
||||||
|
"/data/playlists", "42-my-favorites.m3u8",
|
||||||
|
)
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf(
|
||||||
|
"playlistFilePath() = %q, want %q",
|
||||||
|
result, expected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAndParseM3U8(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
entries := []m3uEntry{
|
||||||
|
{
|
||||||
|
RelativePath: "Artist/Album/01 - Song.flac",
|
||||||
|
DurationSec: 243,
|
||||||
|
DisplayTitle: "Artist - Song",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
RelativePath: "Other/Track.mp3",
|
||||||
|
DurationSec: 180,
|
||||||
|
DisplayTitle: "Other - Track",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := writeM3U8(dir, 1, "Test Playlist", entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeM3U8() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file exists.
|
||||||
|
expectedPath := filepath.Join(dir, "1-test-playlist.m3u8")
|
||||||
|
if _, err := os.Stat(expectedPath); err != nil {
|
||||||
|
t.Fatalf("expected file %q to exist: %v", expectedPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse it back.
|
||||||
|
parsed, err := parseM3U8(expectedPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseM3U8() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Name != "Test Playlist" {
|
||||||
|
t.Errorf(
|
||||||
|
"parsed.Name = %q, want %q",
|
||||||
|
parsed.Name, "Test Playlist",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parsed.Entries) != len(entries) {
|
||||||
|
t.Fatalf(
|
||||||
|
"parsed %d entries, want %d",
|
||||||
|
len(parsed.Entries), len(entries),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, entry := range parsed.Entries {
|
||||||
|
if entry.RelativePath != entries[i].RelativePath {
|
||||||
|
t.Errorf(
|
||||||
|
"entry[%d].RelativePath = %q, want %q",
|
||||||
|
i, entry.RelativePath,
|
||||||
|
entries[i].RelativePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.DurationSec != entries[i].DurationSec {
|
||||||
|
t.Errorf(
|
||||||
|
"entry[%d].DurationSec = %d, want %d",
|
||||||
|
i, entry.DurationSec,
|
||||||
|
entries[i].DurationSec,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.DisplayTitle != entries[i].DisplayTitle {
|
||||||
|
t.Errorf(
|
||||||
|
"entry[%d].DisplayTitle = %q, want %q",
|
||||||
|
i, entry.DisplayTitle,
|
||||||
|
entries[i].DisplayTitle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteM3U8EmptyPlaylist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
err := writeM3U8(dir, 5, "Empty", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeM3U8() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := parseM3U8(
|
||||||
|
filepath.Join(dir, "5-empty.m3u8"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseM3U8() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Name != "Empty" {
|
||||||
|
t.Errorf("parsed.Name = %q, want %q", parsed.Name, "Empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parsed.Entries) != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"parsed %d entries, want 0",
|
||||||
|
len(parsed.Entries),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteM3U8EmptyDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := writeM3U8("", 1, "test", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty dir path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseM3U8InvalidFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
badFile := filepath.Join(dir, "bad.m3u8")
|
||||||
|
|
||||||
|
// Write a file without the M3U header.
|
||||||
|
err := os.WriteFile(
|
||||||
|
badFile,
|
||||||
|
[]byte("just some text\n"),
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = parseM3U8(badFile)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid M3U file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseM3U8NonExistentFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := parseM3U8("/nonexistent/file.m3u8")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-existent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToRelativePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
absPath string
|
||||||
|
libraryRoot string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal relative",
|
||||||
|
absPath: "/music/Artist/Album/song.flac",
|
||||||
|
libraryRoot: "/music",
|
||||||
|
expected: "Artist/Album/song.flac",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path outside library root",
|
||||||
|
absPath: "/other/song.flac",
|
||||||
|
libraryRoot: "/music",
|
||||||
|
expected: "/other/song.flac",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty library root",
|
||||||
|
absPath: "/music/song.flac",
|
||||||
|
libraryRoot: "",
|
||||||
|
expected: "/music/song.flac",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result := toRelativePath(
|
||||||
|
tt.absPath, tt.libraryRoot,
|
||||||
|
)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf(
|
||||||
|
"toRelativePath(%q, %q) = %q, want %q",
|
||||||
|
tt.absPath, tt.libraryRoot,
|
||||||
|
result, tt.expected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToAbsolutePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
relPath string
|
||||||
|
libraryRoot string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "relative path",
|
||||||
|
relPath: "Artist/Album/song.flac",
|
||||||
|
libraryRoot: "/music",
|
||||||
|
expected: "/music/Artist/Album/song.flac",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "already absolute",
|
||||||
|
relPath: "/music/song.flac",
|
||||||
|
libraryRoot: "/other",
|
||||||
|
expected: "/music/song.flac",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result := toAbsolutePath(
|
||||||
|
tt.relPath, tt.libraryRoot,
|
||||||
|
)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf(
|
||||||
|
"toAbsolutePath(%q, %q) = %q, want %q",
|
||||||
|
tt.relPath, tt.libraryRoot,
|
||||||
|
result, tt.expected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayTitle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
artist string
|
||||||
|
title string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "both present",
|
||||||
|
artist: "Artist",
|
||||||
|
title: "Title",
|
||||||
|
expected: "Artist - Title",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "artist only",
|
||||||
|
artist: "Artist",
|
||||||
|
title: "",
|
||||||
|
expected: "Artist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "title only",
|
||||||
|
artist: "",
|
||||||
|
title: "Title",
|
||||||
|
expected: "Title",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "neither present",
|
||||||
|
artist: "",
|
||||||
|
title: "",
|
||||||
|
expected: "Unknown",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result := displayTitle(tt.artist, tt.title)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf(
|
||||||
|
"displayTitle(%q, %q) = %q, want %q",
|
||||||
|
tt.artist, tt.title,
|
||||||
|
result, tt.expected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractPlaylistID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filePath string
|
||||||
|
expected int64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal ID-prefixed filename",
|
||||||
|
filePath: "/data/playlists/42-my-favorites.m3u8",
|
||||||
|
expected: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no ID prefix",
|
||||||
|
filePath: "/data/playlists/my-favorites.m3u8",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ID only",
|
||||||
|
filePath: "/data/playlists/1-.m3u8",
|
||||||
|
expected: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result := extractPlaylistID(tt.filePath)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf(
|
||||||
|
"extractPlaylistID(%q) = %d, want %d",
|
||||||
|
tt.filePath, result, tt.expected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindPlaylistFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a playlist file.
|
||||||
|
err := writeM3U8(dir, 7, "Test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeM3U8() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find it.
|
||||||
|
found, err := findPlaylistFile(dir, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findPlaylistFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if found == "" {
|
||||||
|
t.Fatal("expected to find playlist file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find a non-existent ID.
|
||||||
|
found, err = findPlaylistFile(dir, 999)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findPlaylistFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if found != "" {
|
||||||
|
t.Errorf("expected empty string, got %q", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidM3UExtension(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
ext string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{".m3u", true},
|
||||||
|
{".m3u8", true},
|
||||||
|
{".M3U", true},
|
||||||
|
{".M3U8", true},
|
||||||
|
{".mp3", false},
|
||||||
|
{".txt", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.ext, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result := isValidM3UExtension(tt.ext)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf(
|
||||||
|
"isValidM3UExtension(%q) = %v, want %v",
|
||||||
|
tt.ext, result, tt.expected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveOldPlaylistFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create an initial playlist file.
|
||||||
|
err := writeM3U8(dir, 3, "Old Name", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeM3U8() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldPath := filepath.Join(dir, "3-old-name.m3u8")
|
||||||
|
if _, err := os.Stat(oldPath); err != nil {
|
||||||
|
t.Fatalf("old file should exist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write with a new name — should remove the old file.
|
||||||
|
err = writeM3U8(dir, 3, "New Name", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeM3U8() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old file should be gone.
|
||||||
|
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
|
||||||
|
t.Error("old file should have been removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// New file should exist.
|
||||||
|
newPath := filepath.Join(dir, "3-new-name.m3u8")
|
||||||
|
if _, err := os.Stat(newPath); err != nil {
|
||||||
|
t.Errorf("new file should exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPlaylistFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create some playlist files.
|
||||||
|
for i := int64(1); i <= 3; i++ {
|
||||||
|
if err := writeM3U8(
|
||||||
|
dir, i, "playlist", nil,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("writeM3U8() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also create a non-m3u8 file that should be ignored.
|
||||||
|
err := os.WriteFile(
|
||||||
|
filepath.Join(dir, "notes.txt"),
|
||||||
|
[]byte("test"),
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not create decoy file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := listPlaylistFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listPlaylistFiles() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) != 3 {
|
||||||
|
t.Errorf("found %d files, want 3", len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseExtInf(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
expectedDur int
|
||||||
|
expectedName string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "standard EXTINF",
|
||||||
|
line: "#EXTINF:243,Artist - Title",
|
||||||
|
expectedDur: 243,
|
||||||
|
expectedName: "Artist - Title",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duration only",
|
||||||
|
line: "#EXTINF:180",
|
||||||
|
expectedDur: 180,
|
||||||
|
expectedName: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero duration",
|
||||||
|
line: "#EXTINF:0,Some Title",
|
||||||
|
expectedDur: 0,
|
||||||
|
expectedName: "Some Title",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dur, title := parseExtInf(tt.line)
|
||||||
|
if dur != tt.expectedDur {
|
||||||
|
t.Errorf(
|
||||||
|
"duration = %d, want %d",
|
||||||
|
dur, tt.expectedDur,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if title != tt.expectedName {
|
||||||
|
t.Errorf(
|
||||||
|
"title = %q, want %q",
|
||||||
|
title, tt.expectedName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+863
-54
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,11 @@ import {
|
|||||||
CreatePlaylist,
|
CreatePlaylist,
|
||||||
AddTracksToPlaylist,
|
AddTracksToPlaylist,
|
||||||
RemoveTracksFromPlaylist,
|
RemoveTracksFromPlaylist,
|
||||||
|
DeletePlaylist,
|
||||||
|
RenamePlaylist,
|
||||||
|
ImportPlaylist,
|
||||||
} from '@go/playlist/Service';
|
} from '@go/playlist/Service';
|
||||||
|
import { PlaylistFilePicker } from '@go/frontendutil/FrontendUtil';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import type { playlist } from '@go/models';
|
import type { playlist } from '@go/models';
|
||||||
import { queueStore } from '@store/queue-store';
|
import { queueStore } from '@store/queue-store';
|
||||||
@@ -67,6 +71,10 @@ export class PlaylistView
|
|||||||
@state() private newPlaylistName = '';
|
@state() private newPlaylistName = '';
|
||||||
@state() private contextMenuOpen = false;
|
@state() private contextMenuOpen = false;
|
||||||
@state() private playlistSubmenuOpen = false;
|
@state() private playlistSubmenuOpen = false;
|
||||||
|
@state() private playlistContextMenuOpen = false;
|
||||||
|
@state() private playlistContextMenuIndex = -1;
|
||||||
|
@state() private renamingPlaylistIndex = -1;
|
||||||
|
@state() private renameValue = '';
|
||||||
|
|
||||||
/** Index of the playlist currently hovered during a drag. */
|
/** Index of the playlist currently hovered during a drag. */
|
||||||
@state() private dragOverPlaylistIndex = -1;
|
@state() private dragOverPlaylistIndex = -1;
|
||||||
@@ -79,8 +87,13 @@ export class PlaylistView
|
|||||||
@query('#playlist-submenu')
|
@query('#playlist-submenu')
|
||||||
private playlistSubmenuPopup!: HTMLElement;
|
private playlistSubmenuPopup!: HTMLElement;
|
||||||
|
|
||||||
private closeContextMenuHandler = () =>
|
@query('#playlist-context-menu')
|
||||||
|
private playlistContextMenuPopup!: HTMLElement;
|
||||||
|
|
||||||
|
private closeContextMenuHandler = () => {
|
||||||
this.closeContextMenu();
|
this.closeContextMenu();
|
||||||
|
this.closePlaylistContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
private clearSelectionHandler = (e: MouseEvent) => {
|
private clearSelectionHandler = (e: MouseEvent) => {
|
||||||
const path = e.composedPath();
|
const path = e.composedPath();
|
||||||
@@ -399,6 +412,26 @@ export class PlaylistView
|
|||||||
background-color: rgba(100, 160, 255, 0.15);
|
background-color: rgba(100, 160, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.track-item.phantom {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.phantom:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phantom-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #e67700;
|
||||||
|
background: rgba(230, 119, 0, 0.15);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.track-item:last-child {
|
.track-item:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
@@ -472,6 +505,42 @@ export class PlaylistView
|
|||||||
#playlist-submenu {
|
#playlist-submenu {
|
||||||
z-index: 210;
|
z-index: 210;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#playlist-context-menu {
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rename-input {
|
||||||
|
flex: 1;
|
||||||
|
background: #2a2d30;
|
||||||
|
border: 1px solid #ffd43b;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-button {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-button:hover {
|
||||||
|
border-color: #ffd43b;
|
||||||
|
color: #ffd43b;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
@@ -599,11 +668,14 @@ export class PlaylistView
|
|||||||
private handlePlayAll = (index: number) => {
|
private handlePlayAll = (index: number) => {
|
||||||
const entry = this.entries[index];
|
const entry = this.entries[index];
|
||||||
|
|
||||||
if (!entry || entry.tracks.length === 0) return;
|
if (!entry || entry.tracks.length === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
const filePaths = entry.tracks.map(
|
const filePaths = entry.tracks
|
||||||
(t) => t.FilePath,
|
.filter((t) => !t.Phantom)
|
||||||
);
|
.map((t) => t.FilePath);
|
||||||
|
|
||||||
|
if (filePaths.length === 0) return;
|
||||||
|
|
||||||
queueStore.setQueue(filePaths, 0);
|
queueStore.setQueue(filePaths, 0);
|
||||||
};
|
};
|
||||||
@@ -960,6 +1032,189 @@ export class PlaylistView
|
|||||||
return currentTrack.filePath === track.FilePath;
|
return currentTrack.filePath === track.FilePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Playlist-level context menu (rename, delete)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private handlePlaylistContextMenu = (
|
||||||
|
e: MouseEvent,
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
this.closeContextMenu();
|
||||||
|
this.playlistContextMenuIndex = index;
|
||||||
|
this.playlistContextMenuOpen = true;
|
||||||
|
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
const popup =
|
||||||
|
this.playlistContextMenuPopup;
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
(popup as any).anchor = {
|
||||||
|
getBoundingClientRect() {
|
||||||
|
return {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
top: e.clientY,
|
||||||
|
left: e.clientX,
|
||||||
|
right: e.clientX,
|
||||||
|
bottom: e.clientY,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(popup as any).active = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private closePlaylistContextMenu() {
|
||||||
|
if (!this.playlistContextMenuOpen) return;
|
||||||
|
|
||||||
|
this.playlistContextMenuOpen = false;
|
||||||
|
this.playlistContextMenuIndex = -1;
|
||||||
|
|
||||||
|
const popup =
|
||||||
|
this.playlistContextMenuPopup;
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
(popup as any).active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPlaylistContextAction(
|
||||||
|
action: string,
|
||||||
|
) {
|
||||||
|
const index =
|
||||||
|
this.playlistContextMenuIndex;
|
||||||
|
const entry = this.entries[index];
|
||||||
|
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'rename':
|
||||||
|
this.renamingPlaylistIndex = index;
|
||||||
|
this.renameValue =
|
||||||
|
entry.summary.Name;
|
||||||
|
|
||||||
|
void this.updateComplete.then(
|
||||||
|
() => {
|
||||||
|
const input =
|
||||||
|
this.shadowRoot?.querySelector<HTMLInputElement>(
|
||||||
|
'.rename-input',
|
||||||
|
);
|
||||||
|
|
||||||
|
input?.focus();
|
||||||
|
input?.select();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
void this.handleDeletePlaylist(
|
||||||
|
entry.summary.ID,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closePlaylistContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDeletePlaylist(
|
||||||
|
playlistID: number,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await DeletePlaylist(playlistID);
|
||||||
|
this.playlistCtrl.invalidate();
|
||||||
|
await this.loadPlaylists();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
'Failed to delete playlist:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRenameKeydown = async (
|
||||||
|
e: KeyboardEvent,
|
||||||
|
) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
await this.submitRename();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
this.renamingPlaylistIndex = -1;
|
||||||
|
this.renameValue = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleRenameBlur = async () => {
|
||||||
|
await this.submitRename();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleRenameInput = (e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
this.renameValue = input.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
private async submitRename() {
|
||||||
|
const index = this.renamingPlaylistIndex;
|
||||||
|
|
||||||
|
if (index < 0) return;
|
||||||
|
|
||||||
|
const entry = this.entries[index];
|
||||||
|
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const trimmed = this.renameValue.trim();
|
||||||
|
|
||||||
|
this.renamingPlaylistIndex = -1;
|
||||||
|
this.renameValue = '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
!trimmed ||
|
||||||
|
trimmed === entry.summary.Name
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await RenamePlaylist(
|
||||||
|
entry.summary.ID,
|
||||||
|
trimmed,
|
||||||
|
);
|
||||||
|
this.playlistCtrl.invalidate();
|
||||||
|
await this.loadPlaylists();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
'Failed to rename playlist:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Import playlist
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private handleImportPlaylist = async () => {
|
||||||
|
try {
|
||||||
|
const filePath =
|
||||||
|
await PlaylistFilePicker();
|
||||||
|
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
await ImportPlaylist(filePath);
|
||||||
|
this.playlistCtrl.invalidate();
|
||||||
|
await this.loadPlaylists();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
'Failed to import playlist:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Create playlist
|
// Create playlist
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -1022,13 +1277,30 @@ export class PlaylistView
|
|||||||
return html`
|
return html`
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h2>Playlists</h2>
|
<h2>Playlists</h2>
|
||||||
<button
|
<div
|
||||||
class="new-playlist-button"
|
style="display: flex; gap: 8px;"
|
||||||
@click=${this.handleNewPlaylistClick}
|
|
||||||
>
|
>
|
||||||
<wa-icon name="plus"></wa-icon>
|
<button
|
||||||
New Playlist
|
class="import-button"
|
||||||
</button>
|
@click=${this
|
||||||
|
.handleImportPlaylist}
|
||||||
|
>
|
||||||
|
<wa-icon
|
||||||
|
name="file-import"
|
||||||
|
></wa-icon>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="new-playlist-button"
|
||||||
|
@click=${this
|
||||||
|
.handleNewPlaylistClick}
|
||||||
|
>
|
||||||
|
<wa-icon
|
||||||
|
name="plus"
|
||||||
|
></wa-icon>
|
||||||
|
New Playlist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${this.creating
|
${this.creating
|
||||||
@@ -1143,6 +1415,48 @@ export class PlaylistView
|
|||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
</wa-popup>
|
</wa-popup>
|
||||||
|
|
||||||
|
<wa-popup
|
||||||
|
id="playlist-context-menu"
|
||||||
|
placement="bottom-start"
|
||||||
|
flip
|
||||||
|
shift
|
||||||
|
.active=${this
|
||||||
|
.playlistContextMenuOpen}
|
||||||
|
>
|
||||||
|
${this.playlistContextMenuOpen
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
class="context-menu-panel"
|
||||||
|
>
|
||||||
|
<wa-dropdown-item
|
||||||
|
@click=${() =>
|
||||||
|
this.onPlaylistContextAction(
|
||||||
|
'rename',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<wa-icon
|
||||||
|
slot="icon"
|
||||||
|
name="pen"
|
||||||
|
></wa-icon>
|
||||||
|
Rename
|
||||||
|
</wa-dropdown-item>
|
||||||
|
<wa-dropdown-item
|
||||||
|
@click=${() =>
|
||||||
|
this.onPlaylistContextAction(
|
||||||
|
'delete',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<wa-icon
|
||||||
|
slot="icon"
|
||||||
|
name="trash"
|
||||||
|
></wa-icon>
|
||||||
|
Delete Playlist
|
||||||
|
</wa-dropdown-item>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</wa-popup>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1209,6 +1523,9 @@ export class PlaylistView
|
|||||||
const isDragOver =
|
const isDragOver =
|
||||||
this.dragOverPlaylistIndex === index;
|
this.dragOverPlaylistIndex === index;
|
||||||
|
|
||||||
|
const isRenaming =
|
||||||
|
this.renamingPlaylistIndex === index;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<li
|
<li
|
||||||
class="playlist-item ${isDragOver
|
class="playlist-item ${isDragOver
|
||||||
@@ -1225,6 +1542,11 @@ export class PlaylistView
|
|||||||
class="playlist-header"
|
class="playlist-header"
|
||||||
@click=${() =>
|
@click=${() =>
|
||||||
this.handleToggle(index)}
|
this.handleToggle(index)}
|
||||||
|
@contextmenu=${(e: MouseEvent) =>
|
||||||
|
this.handlePlaylistContextMenu(
|
||||||
|
e,
|
||||||
|
index,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
class="chevron ${entry.expanded
|
class="chevron ${entry.expanded
|
||||||
@@ -1236,9 +1558,33 @@ export class PlaylistView
|
|||||||
class="playlist-icon"
|
class="playlist-icon"
|
||||||
name="list"
|
name="list"
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
<span class="playlist-name">
|
${isRenaming
|
||||||
${entry.summary.Name}
|
? html`
|
||||||
</span>
|
<input
|
||||||
|
class="rename-input"
|
||||||
|
type="text"
|
||||||
|
.value=${this
|
||||||
|
.renameValue}
|
||||||
|
@input=${this
|
||||||
|
.handleRenameInput}
|
||||||
|
@keydown=${this
|
||||||
|
.handleRenameKeydown}
|
||||||
|
@blur=${this
|
||||||
|
.handleRenameBlur}
|
||||||
|
@click=${(
|
||||||
|
e: Event,
|
||||||
|
) =>
|
||||||
|
e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<span
|
||||||
|
class="playlist-name"
|
||||||
|
>
|
||||||
|
${entry.summary
|
||||||
|
.Name}
|
||||||
|
</span>
|
||||||
|
`}
|
||||||
<span class="track-count">
|
<span class="track-count">
|
||||||
${countLabel}
|
${countLabel}
|
||||||
</span>
|
</span>
|
||||||
@@ -1285,9 +1631,15 @@ export class PlaylistView
|
|||||||
</div>
|
</div>
|
||||||
${entry.tracks.map(
|
${entry.tracks.map(
|
||||||
(track, trackIndex) => {
|
(track, trackIndex) => {
|
||||||
|
const isPhantom =
|
||||||
|
track.Phantom;
|
||||||
const active =
|
const active =
|
||||||
this.isActiveTrack(track);
|
!isPhantom &&
|
||||||
|
this.isActiveTrack(
|
||||||
|
track,
|
||||||
|
);
|
||||||
const selected =
|
const selected =
|
||||||
|
!isPhantom &&
|
||||||
this.activePlaylistIndex ===
|
this.activePlaylistIndex ===
|
||||||
playlistIndex &&
|
playlistIndex &&
|
||||||
this.selection.isSelected(
|
this.selection.isSelected(
|
||||||
@@ -1297,7 +1649,12 @@ export class PlaylistView
|
|||||||
const classes = [
|
const classes = [
|
||||||
'track-item',
|
'track-item',
|
||||||
active ? 'active' : '',
|
active ? 'active' : '',
|
||||||
selected ? 'selected' : '',
|
selected
|
||||||
|
? 'selected'
|
||||||
|
: '',
|
||||||
|
isPhantom
|
||||||
|
? 'phantom'
|
||||||
|
: '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
@@ -1305,48 +1662,68 @@ export class PlaylistView
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classes}
|
class=${classes}
|
||||||
draggable="true"
|
draggable=${isPhantom
|
||||||
@click=${(
|
? 'false'
|
||||||
e: MouseEvent,
|
: 'true'}
|
||||||
) =>
|
@click=${isPhantom
|
||||||
this.handleTrackClick(
|
? nothing
|
||||||
e,
|
: (
|
||||||
track,
|
e: MouseEvent,
|
||||||
trackIndex,
|
) =>
|
||||||
playlistIndex,
|
this.handleTrackClick(
|
||||||
)}
|
e,
|
||||||
@dblclick=${() =>
|
track,
|
||||||
this.handleTrackDblClick(
|
trackIndex,
|
||||||
track,
|
playlistIndex,
|
||||||
trackIndex,
|
)}
|
||||||
playlistIndex,
|
@dblclick=${isPhantom
|
||||||
)}
|
? nothing
|
||||||
@contextmenu=${(
|
: () =>
|
||||||
e: MouseEvent,
|
this.handleTrackDblClick(
|
||||||
) =>
|
track,
|
||||||
this.handleTrackContextMenu(
|
trackIndex,
|
||||||
e,
|
playlistIndex,
|
||||||
trackIndex,
|
)}
|
||||||
playlistIndex,
|
@contextmenu=${isPhantom
|
||||||
)}
|
? nothing
|
||||||
@dragstart=${(
|
: (
|
||||||
e: DragEvent,
|
e: MouseEvent,
|
||||||
) =>
|
) =>
|
||||||
this.onTrackDragStart(
|
this.handleTrackContextMenu(
|
||||||
e,
|
e,
|
||||||
track,
|
trackIndex,
|
||||||
trackIndex,
|
playlistIndex,
|
||||||
playlistIndex,
|
)}
|
||||||
)}
|
@dragstart=${isPhantom
|
||||||
@dragend=${this
|
? nothing
|
||||||
.onTrackDragEnd}
|
: (
|
||||||
|
e: DragEvent,
|
||||||
|
) =>
|
||||||
|
this.onTrackDragStart(
|
||||||
|
e,
|
||||||
|
track,
|
||||||
|
trackIndex,
|
||||||
|
playlistIndex,
|
||||||
|
)}
|
||||||
|
@dragend=${isPhantom
|
||||||
|
? nothing
|
||||||
|
: this
|
||||||
|
.onTrackDragEnd}
|
||||||
>
|
>
|
||||||
<track-info
|
<track-info
|
||||||
.trackTitle=${track.Title}
|
.trackTitle=${track.Title ||
|
||||||
|
track.FilePath}
|
||||||
.artist=${track.Artist}
|
.artist=${track.Artist}
|
||||||
.duration=${track.Duration}
|
.duration=${track.Duration}
|
||||||
.filePath=${track.FilePath}
|
.filePath=${track.FilePath}
|
||||||
></track-info>
|
></track-info>
|
||||||
|
${isPhantom
|
||||||
|
? html`<span
|
||||||
|
class="phantom-badge"
|
||||||
|
>File not
|
||||||
|
found</span
|
||||||
|
>`
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ export const Events = {
|
|||||||
RequestInsertTracksAtIndex: "RequestInsertTracksAtIndex",
|
RequestInsertTracksAtIndex: "RequestInsertTracksAtIndex",
|
||||||
RequestMoveQueueTracks: "RequestMoveQueueTracks",
|
RequestMoveQueueTracks: "RequestMoveQueueTracks",
|
||||||
|
|
||||||
|
// Playlist events
|
||||||
|
PlaylistCreated: "PlaylistCreated",
|
||||||
|
PlaylistDeleted: "PlaylistDeleted",
|
||||||
|
PlaylistRenamed: "PlaylistRenamed",
|
||||||
|
PlaylistTracksChanged: "PlaylistTracksChanged",
|
||||||
|
PlaylistsRestored: "PlaylistsRestored",
|
||||||
|
|
||||||
// Library events
|
// Library events
|
||||||
LibraryScanStarted: "LibraryScanStarted",
|
LibraryScanStarted: "LibraryScanStarted",
|
||||||
LibraryScanComplete: "LibraryScanComplete",
|
LibraryScanComplete: "LibraryScanComplete",
|
||||||
|
|||||||
@@ -15,6 +15,26 @@ class PlaylistStore {
|
|||||||
EventsOn(Events.LibraryScanComplete, () => {
|
EventsOn(Events.LibraryScanComplete, () => {
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.PlaylistCreated, () => {
|
||||||
|
this.invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.PlaylistDeleted, () => {
|
||||||
|
this.invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.PlaylistRenamed, () => {
|
||||||
|
this.invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.PlaylistTracksChanged, () => {
|
||||||
|
this.invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.PlaylistsRestored, () => {
|
||||||
|
this.invalidate();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ import {context} from '../models';
|
|||||||
|
|
||||||
export function DirectoryPicker():Promise<string>;
|
export function DirectoryPicker():Promise<string>;
|
||||||
|
|
||||||
|
export function PlaylistFilePicker():Promise<string>;
|
||||||
|
|
||||||
export function SetContext(arg1:context.Context):Promise<void>;
|
export function SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export function DirectoryPicker() {
|
|||||||
return window['go']['frontendutil']['FrontendUtil']['DirectoryPicker']();
|
return window['go']['frontendutil']['FrontendUtil']['DirectoryPicker']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PlaylistFilePicker() {
|
||||||
|
return window['go']['frontendutil']['FrontendUtil']['PlaylistFilePicker']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SetContext(arg1) {
|
export function SetContext(arg1) {
|
||||||
return window['go']['frontendutil']['FrontendUtil']['SetContext'](arg1);
|
return window['go']['frontendutil']['FrontendUtil']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -15,4 +15,6 @@ export function Scan():Promise<library.ScanMetrics>;
|
|||||||
|
|
||||||
export function SetContext(arg1:context.Context):Promise<void>;
|
export function SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|
||||||
|
export function SetPlaylistRestorer(arg1:library.playlistRestorer):Promise<void>;
|
||||||
|
|
||||||
export function SetQueue(arg1:library.queueClearer):Promise<void>;
|
export function SetQueue(arg1:library.queueClearer):Promise<void>;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export function SetContext(arg1) {
|
|||||||
return window['go']['library']['Library']['SetContext'](arg1);
|
return window['go']['library']['Library']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetPlaylistRestorer(arg1) {
|
||||||
|
return window['go']['library']['Library']['SetPlaylistRestorer'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetQueue(arg1) {
|
export function SetQueue(arg1) {
|
||||||
return window['go']['library']['Library']['SetQueue'](arg1);
|
return window['go']['library']['Library']['SetQueue'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,6 +425,7 @@ export namespace playlist {
|
|||||||
CoverArtMedium: string;
|
CoverArtMedium: string;
|
||||||
CoverArtLarge: string;
|
CoverArtLarge: string;
|
||||||
Duration: string;
|
Duration: string;
|
||||||
|
Phantom: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Track(source);
|
return new Track(source);
|
||||||
@@ -443,6 +444,7 @@ export namespace playlist {
|
|||||||
this.CoverArtMedium = source["CoverArtMedium"];
|
this.CoverArtMedium = source["CoverArtMedium"];
|
||||||
this.CoverArtLarge = source["CoverArtLarge"];
|
this.CoverArtLarge = source["CoverArtLarge"];
|
||||||
this.Duration = source["Duration"];
|
this.Duration = source["Duration"];
|
||||||
|
this.Phantom = source["Phantom"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class WithTracks {
|
export class WithTracks {
|
||||||
|
|||||||
+8
@@ -9,12 +9,20 @@ export function CreatePlaylist(arg1:string):Promise<playlist.Summary>;
|
|||||||
|
|
||||||
export function CreatePlaylistWithTracks(arg1:string,arg2:Array<string>):Promise<playlist.Summary>;
|
export function CreatePlaylistWithTracks(arg1:string,arg2:Array<string>):Promise<playlist.Summary>;
|
||||||
|
|
||||||
|
export function DeletePlaylist(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function GetAllPlaylists():Promise<Array<playlist.Summary>>;
|
export function GetAllPlaylists():Promise<Array<playlist.Summary>>;
|
||||||
|
|
||||||
export function GetAllPlaylistsWithTracks():Promise<Array<playlist.WithTracks>>;
|
export function GetAllPlaylistsWithTracks():Promise<Array<playlist.WithTracks>>;
|
||||||
|
|
||||||
export function GetPlaylistTracks(arg1:number):Promise<Array<playlist.Track>>;
|
export function GetPlaylistTracks(arg1:number):Promise<Array<playlist.Track>>;
|
||||||
|
|
||||||
|
export function ImportPlaylist(arg1:string):Promise<playlist.Summary>;
|
||||||
|
|
||||||
export function RemoveTracksFromPlaylist(arg1:number,arg2:Array<number>):Promise<void>;
|
export function RemoveTracksFromPlaylist(arg1:number,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
|
export function RenamePlaylist(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RestoreAllPlaylists():Promise<void>;
|
||||||
|
|
||||||
export function SetContext(arg1:context.Context):Promise<void>;
|
export function SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export function CreatePlaylistWithTracks(arg1, arg2) {
|
|||||||
return window['go']['playlist']['Service']['CreatePlaylistWithTracks'](arg1, arg2);
|
return window['go']['playlist']['Service']['CreatePlaylistWithTracks'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DeletePlaylist(arg1) {
|
||||||
|
return window['go']['playlist']['Service']['DeletePlaylist'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAllPlaylists() {
|
export function GetAllPlaylists() {
|
||||||
return window['go']['playlist']['Service']['GetAllPlaylists']();
|
return window['go']['playlist']['Service']['GetAllPlaylists']();
|
||||||
}
|
}
|
||||||
@@ -26,10 +30,22 @@ export function GetPlaylistTracks(arg1) {
|
|||||||
return window['go']['playlist']['Service']['GetPlaylistTracks'](arg1);
|
return window['go']['playlist']['Service']['GetPlaylistTracks'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ImportPlaylist(arg1) {
|
||||||
|
return window['go']['playlist']['Service']['ImportPlaylist'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function RemoveTracksFromPlaylist(arg1, arg2) {
|
export function RemoveTracksFromPlaylist(arg1, arg2) {
|
||||||
return window['go']['playlist']['Service']['RemoveTracksFromPlaylist'](arg1, arg2);
|
return window['go']['playlist']['Service']['RemoveTracksFromPlaylist'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenamePlaylist(arg1, arg2) {
|
||||||
|
return window['go']['playlist']['Service']['RenamePlaylist'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestoreAllPlaylists() {
|
||||||
|
return window['go']['playlist']['Service']['RestoreAllPlaylists']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SetContext(arg1) {
|
export function SetContext(arg1) {
|
||||||
return window['go']['playlist']['Service']['SetContext'](arg1);
|
return window['go']['playlist']['Service']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user