From 560ab3a3b5a22d0e7a057bad1172bcd937cf09e0 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Thu, 19 Feb 2026 23:10:20 -0500 Subject: [PATCH] playlists now save to files, can be restored from files --- backend/app.go | 8 +- backend/events/events.go | 9 + backend/frontendutil/frontendutil.go | 33 +- backend/library/library.go | 24 +- backend/library/rescan.go | 6 + backend/playlist/m3u.go | 430 ++++++++ backend/playlist/m3u_test.go | 594 ++++++++++++ backend/playlist/playlist.go | 917 ++++++++++++++++-- .../components/playlist-view/playlist-view.ts | 481 ++++++++- frontend/src/events.ts | 7 + frontend/src/store/playlist-store.ts | 20 + .../wailsjs/go/frontendutil/FrontendUtil.d.ts | 2 + .../wailsjs/go/frontendutil/FrontendUtil.js | 4 + frontend/wailsjs/go/library/Library.d.ts | 2 + frontend/wailsjs/go/library/Library.js | 4 + frontend/wailsjs/go/models.ts | 2 + frontend/wailsjs/go/playlist/Service.d.ts | 8 + frontend/wailsjs/go/playlist/Service.js | 16 + 18 files changed, 2454 insertions(+), 113 deletions(-) create mode 100644 backend/playlist/m3u.go create mode 100644 backend/playlist/m3u_test.go diff --git a/backend/app.go b/backend/app.go index 4bec25a..e00877d 100644 --- a/backend/app.go +++ b/backend/app.go @@ -96,7 +96,9 @@ func NewYellowJacketApp( yjApp.assetHandler.RegisterHandler("/covers/", coverHandler) // 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.FrontendUtil, @@ -146,6 +148,10 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { // clear the queue and stop playback before wiping data. 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. yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished) diff --git a/backend/events/events.go b/backend/events/events.go index 0536006..e35fe19 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -56,6 +56,15 @@ const ( LibraryConfigChanged = "LibraryConfigChanged" ) +// Playlist events. +const ( + PlaylistCreated = "PlaylistCreated" + PlaylistDeleted = "PlaylistDeleted" + PlaylistRenamed = "PlaylistRenamed" + PlaylistTracksChanged = "PlaylistTracksChanged" + PlaylistsRestored = "PlaylistsRestored" +) + // Library events. const ( LibraryScanStarted = "LibraryScanStarted" diff --git a/backend/frontendutil/frontendutil.go b/backend/frontendutil/frontendutil.go index e06fce1..f6ad8b1 100644 --- a/backend/frontendutil/frontendutil.go +++ b/backend/frontendutil/frontendutil.go @@ -31,8 +31,39 @@ func (fe *FrontendUtil) DirectoryPicker() (string, error) { fe.ctx, runtime.OpenDialogOptions{}) 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 } + +// 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 +} diff --git a/backend/library/library.go b/backend/library/library.go index ce21fe4..29acd7f 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -63,13 +63,20 @@ type queueClearer interface { 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. type Library struct { - ctx context.Context - logger *slog.Logger - conf *Config - db *database.DB - queue queueClearer + ctx context.Context + logger *slog.Logger + conf *Config + db *database.DB + queue queueClearer + playlistRestorer playlistRestorer } // SetQueue provides the library with a reference to the queue so @@ -79,6 +86,13 @@ func (l *Library) SetQueue(q queueClearer) { 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. // A nil config is permitted; the library will be inert until a valid // configuration is supplied via the LibraryConfigChanged event. diff --git a/backend/library/rescan.go b/backend/library/rescan.go index cbb584a..8658494 100644 --- a/backend/library/rescan.go +++ b/backend/library/rescan.go @@ -68,6 +68,12 @@ func (l *Library) FullRescan() (*ScanMetrics, error) { 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 } diff --git a/backend/playlist/m3u.go b/backend/playlist/m3u.go new file mode 100644 index 0000000..e2c61ed --- /dev/null +++ b/backend/playlist/m3u.go @@ -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 +} diff --git a/backend/playlist/m3u_test.go b/backend/playlist/m3u_test.go new file mode 100644 index 0000000..2406f65 --- /dev/null +++ b/backend/playlist/m3u_test.go @@ -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, + ) + } + }) + } +} diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 7679afb..a566538 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -6,28 +6,46 @@ import ( "errors" "fmt" "log/slog" + "os" "path/filepath" "strconv" "strings" + "github.com/wailsapp/wails/v2/pkg/runtime" + "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" + "yellowjacket/backend/events" "yellowjacket/backend/library" + "yellowjacket/backend/system" ) var ( - errEmptyName = errors.New("playlist name cannot be empty") - errEmptyFilePath = errors.New("file path cannot be empty") - errNoFilePaths = errors.New("no file paths provided") + errEmptyName = errors.New("playlist name cannot be empty") + errEmptyFilePath = errors.New("file path cannot be empty") + errNoFilePaths = errors.New("no file paths provided") + errUnsupportedFileType = errors.New("unsupported file type") ) -// Summary is a lightweight representation of a playlist for the picker UI. +// playlistsDirName is the subdirectory within the user data +// directory where M3U8 playlist files are stored. +const playlistsDirName = "playlists" + +// LibraryDirProvider is a narrow interface for obtaining the +// configured library directory path. +type LibraryDirProvider interface { + GetLibraryDirectory() string +} + +// Summary is a lightweight representation of a playlist for the +// picker UI. type Summary struct { ID int64 `json:"ID"` Name string `json:"Name"` } -// Track represents a track within a playlist, including its metadata. +// Track represents a track within a playlist, including its +// metadata. type Track struct { ID int64 `json:"ID"` Position int64 `json:"Position"` @@ -40,6 +58,7 @@ type Track struct { CoverArtMedium string `json:"CoverArtMedium"` CoverArtLarge string `json:"CoverArtLarge"` Duration string `json:"Duration"` + Phantom bool `json:"Phantom"` } // WithTracks contains a playlist summary and all its tracks. @@ -50,37 +69,49 @@ type WithTracks struct { // Service manages playlist operations. type Service struct { - ctx context.Context - logger *slog.Logger - db *database.DB + ctx context.Context + logger *slog.Logger + db *database.DB + libraryDir LibraryDirProvider } // NewService creates a new playlist service. func NewService( logger *slog.Logger, db *database.DB, + libraryDir LibraryDirProvider, ) *Service { return &Service{ - logger: logger.WithGroup("playlist"), - db: db, + logger: logger.WithGroup("playlist"), + db: db, + libraryDir: libraryDir, } } -// SetContext sets the Wails runtime context. +// SetContext sets the Wails runtime context and runs the +// one-time startup migration to bootstrap M3U8 files for +// existing playlists. func (s *Service) SetContext(ctx context.Context) { s.ctx = ctx + s.migrateExistingPlaylists() } -// GetAllPlaylists returns all playlists ordered by most recently updated. +// GetAllPlaylists returns all playlists ordered by most recently +// updated. func (s *Service) GetAllPlaylists() ([]Summary, error) { playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx) if err != nil { - s.logger.Error("Failed to get playlists", "err", err) + s.logger.Error( + "Failed to get playlists", "err", err, + ) - return nil, fmt.Errorf("failed to get playlists: %w", err) + return nil, fmt.Errorf( + "failed to get playlists: %w", err, + ) } summaries := make([]Summary, 0, len(playlists)) + for _, p := range playlists { summaries = append(summaries, Summary{ ID: p.ID, @@ -91,16 +122,21 @@ func (s *Service) GetAllPlaylists() ([]Summary, error) { return summaries, nil } -// GetAllPlaylistsWithTracks returns all playlists with their tracks in a single call. +// GetAllPlaylistsWithTracks returns all playlists with their +// tracks in a single call, merging phantom tracks from M3U8 files. func (s *Service) GetAllPlaylistsWithTracks() ( []WithTracks, error, ) { playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx) if err != nil { - s.logger.Error("Failed to get playlists", "err", err) + s.logger.Error( + "Failed to get playlists", "err", err, + ) - return nil, fmt.Errorf("failed to get playlists: %w", err) + return nil, fmt.Errorf( + "failed to get playlists: %w", err, + ) } rows, err := s.db.Queries.GetAllPlaylistTracksWithMetadata( @@ -118,8 +154,10 @@ func (s *Service) GetAllPlaylistsWithTracks() ( ) } - // Group tracks by playlist ID. - tracksByPlaylist := make(map[int64][]Track) + // Group DB tracks by playlist ID, keyed by absolute file path. + dbTracksByPlaylist := make( + map[int64]map[string]Track, + ) for _, row := range rows { track := trackFromRow( @@ -133,19 +171,23 @@ func (s *Service) GetAllPlaylistsWithTracks() ( row.CoverArtPath, ) - tracksByPlaylist[row.PlaylistID] = append( - tracksByPlaylist[row.PlaylistID], - track, - ) + if dbTracksByPlaylist[row.PlaylistID] == nil { + dbTracksByPlaylist[row.PlaylistID] = make( + map[string]Track, + ) + } + + dbTracksByPlaylist[row.PlaylistID][row.FilePath] = track } result := make([]WithTracks, 0, len(playlists)) for _, p := range playlists { - tracks := tracksByPlaylist[p.ID] - if tracks == nil { - tracks = []Track{} - } + tracks := s.mergeTracksForPlaylist( + p.ID, + p.Name, + dbTracksByPlaylist[p.ID], + ) result = append(result, WithTracks{ Summary: Summary{ID: p.ID, Name: p.Name}, @@ -156,7 +198,8 @@ func (s *Service) GetAllPlaylistsWithTracks() ( return result, nil } -// GetPlaylistTracks returns all tracks in a playlist with full metadata. +// GetPlaylistTracks returns all tracks in a playlist with full +// metadata, merging phantom tracks from the M3U8 file. func (s *Service) GetPlaylistTracks( playlistID int64, ) ([]Track, error) { @@ -177,10 +220,11 @@ func (s *Service) GetPlaylistTracks( ) } - tracks := make([]Track, 0, len(rows)) + // Build a map of DB tracks keyed by absolute file path. + dbTracks := make(map[string]Track, len(rows)) for _, row := range rows { - tracks = append(tracks, trackFromRow( + track := trackFromRow( row.ID, row.Position, row.FilePath, @@ -189,10 +233,106 @@ func (s *Service) GetPlaylistTracks( row.Album, row.LengthMilliseconds, row.CoverArtPath, - )) + ) + + dbTracks[row.FilePath] = track } - return tracks, nil + // Get playlist name for M3U file lookup. + playlist, err := s.db.Queries.GetPlaylist( + s.db.Ctx, playlistID, + ) + if err != nil { + s.logger.Error( + "Failed to get playlist", + "playlistId", playlistID, + "err", err, + ) + + return nil, fmt.Errorf( + "failed to get playlist: %w", err, + ) + } + + return s.mergeTracksForPlaylist( + playlistID, playlist.Name, dbTracks, + ), nil +} + +// mergeTracksForPlaylist merges DB tracks with M3U8 entries, +// producing phantom tracks for unresolved paths. +func (s *Service) mergeTracksForPlaylist( + playlistID int64, + _ string, + dbTracks map[string]Track, +) []Track { + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "Could not get playlists dir for merge", + "err", err, + ) + + return dbTracksToSlice(dbTracks) + } + + m3uPath, err := findPlaylistFile(dir, playlistID) + if err != nil || m3uPath == "" { + return dbTracksToSlice(dbTracks) + } + + parsed, err := parseM3U8(m3uPath) + if err != nil { + s.logger.Warn( + "Could not parse M3U8 for merge", + "playlistId", playlistID, + "path", m3uPath, + "err", err, + ) + + return dbTracksToSlice(dbTracks) + } + + libraryRoot := s.getLibraryRoot() + tracks := make([]Track, 0, len(parsed.Entries)) + + for i, entry := range parsed.Entries { + absPath := toAbsolutePath( + entry.RelativePath, libraryRoot, + ) + + if dbTrack, ok := dbTracks[absPath]; ok { + dbTrack.Position = int64(i) + tracks = append(tracks, dbTrack) + + continue + } + + // Phantom track — file not resolved in DB. + tracks = append(tracks, Track{ + Position: int64(i), + FilePath: absPath, + Title: entry.DisplayTitle, + Phantom: true, + }) + } + + return tracks +} + +// dbTracksToSlice converts a map of tracks to an ordered slice. +func dbTracksToSlice(m map[string]Track) []Track { + if len(m) == 0 { + return []Track{} + } + + tracks := make([]Track, 0, len(m)) + + for _, t := range m { + tracks = append(tracks, t) + } + + return tracks } // trackFromRow converts raw query row fields into a Track. @@ -227,25 +367,45 @@ func trackFromRow( } // CreatePlaylist creates a new empty playlist with the given name. -func (s *Service) CreatePlaylist(name string) (Summary, error) { +func (s *Service) CreatePlaylist( + name string, +) (Summary, error) { trimmed := strings.TrimSpace(name) if trimmed == "" { return Summary{}, errEmptyName } - created, err := s.db.Queries.CreatePlaylist(s.db.Ctx, trimmed) + created, err := s.db.Queries.CreatePlaylist( + s.db.Ctx, trimmed, + ) if err != nil { - s.logger.Error("Failed to create playlist", "name", trimmed, "err", err) + s.logger.Error( + "Failed to create playlist", + "name", trimmed, "err", err, + ) - return Summary{}, fmt.Errorf("failed to create playlist: %w", err) + return Summary{}, fmt.Errorf( + "failed to create playlist: %w", err, + ) } - s.logger.Info("Playlist created", "id", created.ID, "name", created.Name) + s.logger.Info( + "Playlist created", + "id", created.ID, "name", created.Name, + ) - return Summary{ID: created.ID, Name: created.Name}, nil + s.savePlaylistFile(created.ID, created.Name) + s.emitEvent(events.PlaylistCreated, Summary{ + ID: created.ID, Name: created.Name, + }) + + return Summary{ + ID: created.ID, Name: created.Name, + }, nil } -// AddTracksToPlaylist adds one or more tracks to an existing playlist. +// AddTracksToPlaylist adds one or more tracks to an existing +// playlist. func (s *Service) AddTracksToPlaylist( playlistID int64, filePaths []string, @@ -265,11 +425,15 @@ func (s *Service) AddTracksToPlaylist( "err", err, ) - return fmt.Errorf("failed to get next track position: %w", err) + return fmt.Errorf( + "failed to get next track position: %w", err, + ) } for i, fp := range filePaths { - if err := s.addSingleTrack(playlistID, fp, nextPos+int64(i)); err != nil { + if err := s.addSingleTrack( + playlistID, fp, nextPos+int64(i), + ); err != nil { return err } } @@ -280,34 +444,80 @@ func (s *Service) AddTracksToPlaylist( "count", len(filePaths), ) + s.savePlaylistFileByID(playlistID) + s.emitEvent(events.PlaylistTracksChanged, playlistID) + return nil } -// CreatePlaylistWithTracks creates a new playlist and populates it with tracks. +// CreatePlaylistWithTracks creates a new playlist and populates +// it with tracks. func (s *Service) CreatePlaylistWithTracks( name string, filePaths []string, ) (Summary, error) { - summary, err := s.CreatePlaylist(name) + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return Summary{}, errEmptyName + } + + created, err := s.db.Queries.CreatePlaylist( + s.db.Ctx, trimmed, + ) if err != nil { - return Summary{}, err + s.logger.Error( + "Failed to create playlist", + "name", trimmed, "err", err, + ) + + return Summary{}, fmt.Errorf( + "failed to create playlist: %w", err, + ) } if len(filePaths) > 0 { - if err := s.AddTracksToPlaylist(summary.ID, filePaths); err != nil { + nextPos, posErr := s.db.Queries.GetNextPlaylistTrackPosition( + s.db.Ctx, + created.ID, + ) + if posErr != nil { return Summary{}, fmt.Errorf( - "playlist created but failed to add tracks: %w", - err, + "failed to get next track position: %w", + posErr, ) } + + for i, fp := range filePaths { + if err := s.addSingleTrack( + created.ID, fp, nextPos+int64(i), + ); err != nil { + return Summary{}, fmt.Errorf( + "playlist created but failed to add tracks: %w", + err, + ) + } + } } + summary := Summary{ + ID: created.ID, Name: created.Name, + } + + s.logger.Info( + "Playlist created with tracks", + "id", created.ID, + "name", created.Name, + "trackCount", len(filePaths), + ) + + s.savePlaylistFile(created.ID, created.Name) + s.emitEvent(events.PlaylistCreated, summary) + return summary, nil } -// RemoveTracksFromPlaylist removes multiple tracks from a playlist by their -// playlist_track IDs. Each track is removed individually using the existing -// RemovePlaylistTrack query. +// RemoveTracksFromPlaylist removes multiple tracks from a playlist +// by their playlist_track IDs. func (s *Service) RemoveTracksFromPlaylist( playlistID int64, trackIDs []int64, @@ -342,10 +552,336 @@ func (s *Service) RemoveTracksFromPlaylist( "count", len(trackIDs), ) + s.savePlaylistFileByID(playlistID) + s.emitEvent(events.PlaylistTracksChanged, playlistID) + return nil } -// addSingleTrack looks up the audio file by path and inserts it into the playlist. +// DeletePlaylist deletes a playlist and its M3U8 file. +func (s *Service) DeletePlaylist(playlistID int64) error { + if err := s.db.Queries.DeletePlaylist( + s.db.Ctx, playlistID, + ); err != nil { + s.logger.Error( + "Failed to delete playlist", + "playlistId", playlistID, + "err", err, + ) + + return fmt.Errorf( + "failed to delete playlist: %w", err, + ) + } + + s.deletePlaylistFile(playlistID) + + s.logger.Info( + "Playlist deleted", "playlistId", playlistID, + ) + + s.emitEvent(events.PlaylistDeleted, playlistID) + + return nil +} + +// RenamePlaylist renames a playlist and updates its M3U8 file. +func (s *Service) RenamePlaylist( + playlistID int64, + newName string, +) error { + trimmed := strings.TrimSpace(newName) + if trimmed == "" { + return errEmptyName + } + + if err := s.db.Queries.UpdatePlaylistName( + s.db.Ctx, + sqlcgen.UpdatePlaylistNameParams{ + Name: trimmed, + ID: playlistID, + }, + ); err != nil { + s.logger.Error( + "Failed to rename playlist", + "playlistId", playlistID, + "newName", trimmed, + "err", err, + ) + + return fmt.Errorf( + "failed to rename playlist: %w", err, + ) + } + + // Re-save the M3U8 file with the new name (handles rename + // of the file on disk). + s.savePlaylistFile(playlistID, trimmed) + + s.logger.Info( + "Playlist renamed", + "playlistId", playlistID, + "newName", trimmed, + ) + + s.emitEvent(events.PlaylistRenamed, Summary{ + ID: playlistID, Name: trimmed, + }) + + return nil +} + +// ImportPlaylist imports a playlist from an external M3U/M3U8 +// file. It creates a new playlist in the DB, resolves tracks +// against the library, and saves an M3U8 file. +func (s *Service) ImportPlaylist( + filePath string, +) (Summary, error) { + if strings.TrimSpace(filePath) == "" { + return Summary{}, errEmptyFilePath + } + + ext := filepath.Ext(filePath) + if !isValidM3UExtension(ext) { + return Summary{}, fmt.Errorf( + "%w: %q, expected .m3u or .m3u8", + errUnsupportedFileType, ext, + ) + } + + parsed, err := parseM3U8(filePath) + if err != nil { + return Summary{}, fmt.Errorf( + "could not parse playlist file: %w", err, + ) + } + + playlistName := parsed.Name + if playlistName == "" { + base := filepath.Base(filePath) + playlistName = strings.TrimSuffix( + base, filepath.Ext(base), + ) + } + + // Create playlist in DB. + created, err := s.db.Queries.CreatePlaylist( + s.db.Ctx, playlistName, + ) + if err != nil { + return Summary{}, fmt.Errorf( + "could not create playlist for import: %w", err, + ) + } + + libraryRoot := s.getLibraryRoot() + + var ( + resolved int + unresolved int + ) + + for i, entry := range parsed.Entries { + absPath := toAbsolutePath( + entry.RelativePath, libraryRoot, + ) + + audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, absPath, + ) + if lookupErr != nil { + // Track not in library — will appear as phantom. + unresolved++ + + continue + } + + _, addErr := s.db.Queries.AddPlaylistTrack( + s.db.Ctx, + sqlcgen.AddPlaylistTrackParams{ + PlaylistID: created.ID, + AudioFileID: audioFile.ID, + Position: int64(i), + }, + ) + if addErr != nil { + s.logger.Warn( + "Could not add imported track", + "playlistId", created.ID, + "path", absPath, + "err", addErr, + ) + + continue + } + + resolved++ + } + + // Save the M3U8 file with entries (preserves unresolved + // paths for phantom display). + s.saveImportedPlaylistFile( + created.ID, playlistName, parsed.Entries, libraryRoot, + ) + + s.logger.Info( + "Playlist imported", + "id", created.ID, + "name", playlistName, + "resolved", resolved, + "unresolved", unresolved, + ) + + summary := Summary{ + ID: created.ID, Name: playlistName, + } + + s.emitEvent(events.PlaylistCreated, summary) + + return summary, nil +} + +// RestoreAllPlaylists restores playlist tracks from M3U8 files. +// This is called after a full library rescan to repopulate +// playlist_tracks from the surviving M3U8 files. +func (s *Service) RestoreAllPlaylists() { + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "Could not get playlists dir for restore", + "err", err, + ) + + return + } + + files, err := listPlaylistFiles(dir) + if err != nil { + s.logger.Warn( + "Could not list playlist files", + "err", err, + ) + + return + } + + if len(files) == 0 { + return + } + + libraryRoot := s.getLibraryRoot() + + var totalRestored, totalUnresolved int + + for _, file := range files { + playlistID := extractPlaylistID(file) + if playlistID == 0 { + s.logger.Warn( + "Could not extract playlist ID from filename", + "file", file, + ) + + continue + } + + restored, unresolved := s.restoreSinglePlaylist( + playlistID, file, libraryRoot, + ) + + totalRestored += restored + totalUnresolved += unresolved + } + + s.logger.Info( + "All playlists restored from M3U8 files", + "totalRestored", totalRestored, + "totalUnresolved", totalUnresolved, + ) + + s.emitEvent(events.PlaylistsRestored, nil) +} + +// restoreSinglePlaylist restores tracks for a single playlist +// from its M3U8 file. +func (s *Service) restoreSinglePlaylist( + playlistID int64, + m3uPath string, + libraryRoot string, +) (restored, unresolved int) { + parsed, err := parseM3U8(m3uPath) + if err != nil { + s.logger.Warn( + "Could not parse M3U8 for restore", + "playlistId", playlistID, + "path", m3uPath, + "err", err, + ) + + return 0, 0 + } + + // Verify the playlist exists in the DB. + _, err = s.db.Queries.GetPlaylist( + s.db.Ctx, playlistID, + ) + if err != nil { + s.logger.Warn( + "Playlist not found in DB during restore", + "playlistId", playlistID, + "err", err, + ) + + return 0, 0 + } + + for i, entry := range parsed.Entries { + absPath := toAbsolutePath( + entry.RelativePath, libraryRoot, + ) + + audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, absPath, + ) + if lookupErr != nil { + unresolved++ + + continue + } + + _, addErr := s.db.Queries.AddPlaylistTrack( + s.db.Ctx, + sqlcgen.AddPlaylistTrackParams{ + PlaylistID: playlistID, + AudioFileID: audioFile.ID, + Position: int64(i), + }, + ) + if addErr != nil { + s.logger.Warn( + "Could not restore track", + "playlistId", playlistID, + "path", absPath, + "err", addErr, + ) + + continue + } + + restored++ + } + + s.logger.Info( + "Playlist restored", + "playlistId", playlistID, + "restored", restored, + "unresolved", unresolved, + ) + + return restored, unresolved +} + +// addSingleTrack looks up the audio file by path and inserts it +// into the playlist. func (s *Service) addSingleTrack( playlistID int64, filePath string, @@ -355,7 +891,9 @@ func (s *Service) addSingleTrack( return errEmptyFilePath } - audioFile, err := s.db.Queries.GetAudioFileByPath(s.db.Ctx, filePath) + audioFile, err := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, filePath, + ) if err != nil { s.logger.Error( "Failed to find audio file", @@ -363,7 +901,10 @@ func (s *Service) addSingleTrack( "err", err, ) - return fmt.Errorf("failed to find audio file %q: %w", filePath, err) + return fmt.Errorf( + "failed to find audio file %q: %w", + filePath, err, + ) } _, err = s.db.Queries.AddPlaylistTrack( @@ -382,8 +923,276 @@ func (s *Service) addSingleTrack( "err", err, ) - return fmt.Errorf("failed to add track to playlist: %w", err) + return fmt.Errorf( + "failed to add track to playlist: %w", err, + ) } return nil } + +// --- M3U8 file management helpers --- + +// playlistsDir returns the path to the playlists directory, +// creating it if needed. +func (s *Service) playlistsDir() (string, error) { + dataDir, err := system.GetUserDataDirPath() + if err != nil { + return "", fmt.Errorf( + "could not get user data directory: %w", err, + ) + } + + dir := filepath.Join(dataDir, playlistsDirName) + + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return "", fmt.Errorf( + "could not create playlists directory: %w", err, + ) + } + + return dir, nil +} + +// getLibraryRoot returns the configured library directory path. +func (s *Service) getLibraryRoot() string { + if s.libraryDir == nil { + return "" + } + + return s.libraryDir.GetLibraryDirectory() +} + +// savePlaylistFile saves the current state of a playlist to its +// M3U8 file. +func (s *Service) savePlaylistFile( + playlistID int64, + name string, +) { + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "Could not get playlists dir for save", + "err", err, + ) + + return + } + + entries := s.buildM3UEntries(playlistID) + + if err := writeM3U8( + dir, playlistID, name, entries, + ); err != nil { + s.logger.Warn( + "Could not save playlist M3U8 file", + "playlistId", playlistID, + "err", err, + ) + } +} + +// savePlaylistFileByID looks up the playlist name and saves. +func (s *Service) savePlaylistFileByID(playlistID int64) { + playlist, err := s.db.Queries.GetPlaylist( + s.db.Ctx, playlistID, + ) + if err != nil { + s.logger.Warn( + "Could not get playlist for save", + "playlistId", playlistID, + "err", err, + ) + + return + } + + s.savePlaylistFile(playlistID, playlist.Name) +} + +// saveImportedPlaylistFile saves an M3U8 file for an imported +// playlist, preserving the original entries (including +// unresolved paths). +func (s *Service) saveImportedPlaylistFile( + playlistID int64, + name string, + entries []m3uEntry, + libraryRoot string, +) { + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "Could not get playlists dir for import save", + "err", err, + ) + + return + } + + // Convert any absolute paths in entries to relative. + converted := make([]m3uEntry, len(entries)) + + for i, entry := range entries { + converted[i] = m3uEntry{ + RelativePath: toRelativePath( + toAbsolutePath( + entry.RelativePath, libraryRoot, + ), + libraryRoot, + ), + DurationSec: entry.DurationSec, + DisplayTitle: entry.DisplayTitle, + } + } + + if err := writeM3U8( + dir, playlistID, name, converted, + ); err != nil { + s.logger.Warn( + "Could not save imported playlist M3U8 file", + "playlistId", playlistID, + "err", err, + ) + } +} + +// buildM3UEntries builds M3U entries from the current DB state +// of a playlist. +func (s *Service) buildM3UEntries( + playlistID int64, +) []m3uEntry { + rows, err := s.db.Queries.GetPlaylistTracksWithMetadata( + s.db.Ctx, + playlistID, + ) + if err != nil { + s.logger.Warn( + "Could not get tracks for M3U build", + "playlistId", playlistID, + "err", err, + ) + + return nil + } + + libraryRoot := s.getLibraryRoot() + entries := make([]m3uEntry, 0, len(rows)) + + for _, row := range rows { + durationSec := int( + row.LengthMilliseconds / 1000, + ) + + entries = append(entries, m3uEntry{ + RelativePath: toRelativePath( + row.FilePath, libraryRoot, + ), + DurationSec: durationSec, + DisplayTitle: displayTitle( + row.Artist, row.Title, + ), + }) + } + + return entries +} + +// deletePlaylistFile removes the M3U8 file for a playlist. +func (s *Service) deletePlaylistFile(playlistID int64) { + dir, err := s.playlistsDir() + if err != nil { + return + } + + existing, err := findPlaylistFile(dir, playlistID) + if err != nil || existing == "" { + return + } + + if err := os.Remove(existing); err != nil && + !os.IsNotExist(err) { + s.logger.Warn( + "Could not delete playlist file", + "playlistId", playlistID, + "path", existing, + "err", err, + ) + } +} + +// emitEvent emits a Wails event if the context is available. +func (s *Service) emitEvent( + eventName string, + data any, +) { + if s.ctx == nil { + return + } + + runtime.EventsEmit(s.ctx, eventName, data) +} + +// migrateExistingPlaylists generates M3U8 files for any +// existing DB playlists that don't already have one. This runs +// once at startup to bootstrap the file-based backup for users +// who already have playlists. +func (s *Service) migrateExistingPlaylists() { + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "Could not get playlists dir for migration", + "err", err, + ) + + return + } + + existingFiles, err := listPlaylistFiles(dir) + if err != nil { + s.logger.Warn( + "Could not list existing playlist files", + "err", err, + ) + + return + } + + // Build a set of IDs that already have files. + existingIDs := make(map[int64]struct{}) + + for _, file := range existingFiles { + id := extractPlaylistID(file) + if id > 0 { + existingIDs[id] = struct{}{} + } + } + + playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx) + if err != nil { + s.logger.Warn( + "Could not get playlists for migration", + "err", err, + ) + + return + } + + var migrated int + + for _, p := range playlists { + if _, exists := existingIDs[p.ID]; exists { + continue + } + + s.savePlaylistFile(p.ID, p.Name) + + migrated++ + } + + if migrated > 0 { + s.logger.Info( + "Migrated existing playlists to M3U8 files", + "count", migrated, + ) + } +} diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index 3b7c605..47b1976 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -10,7 +10,11 @@ import { CreatePlaylist, AddTracksToPlaylist, RemoveTracksFromPlaylist, + DeletePlaylist, + RenamePlaylist, + ImportPlaylist, } from '@go/playlist/Service'; +import { PlaylistFilePicker } from '@go/frontendutil/FrontendUtil'; import { Events } from '../../events'; import type { playlist } from '@go/models'; import { queueStore } from '@store/queue-store'; @@ -67,6 +71,10 @@ export class PlaylistView @state() private newPlaylistName = ''; @state() private contextMenuOpen = 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. */ @state() private dragOverPlaylistIndex = -1; @@ -79,8 +87,13 @@ export class PlaylistView @query('#playlist-submenu') private playlistSubmenuPopup!: HTMLElement; - private closeContextMenuHandler = () => + @query('#playlist-context-menu') + private playlistContextMenuPopup!: HTMLElement; + + private closeContextMenuHandler = () => { this.closeContextMenu(); + this.closePlaylistContextMenu(); + }; private clearSelectionHandler = (e: MouseEvent) => { const path = e.composedPath(); @@ -399,6 +412,26 @@ export class PlaylistView 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 { border-bottom: none; } @@ -472,6 +505,42 @@ export class PlaylistView #playlist-submenu { 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() { @@ -599,11 +668,14 @@ export class PlaylistView private handlePlayAll = (index: number) => { const entry = this.entries[index]; - if (!entry || entry.tracks.length === 0) return; + if (!entry || entry.tracks.length === 0) + return; - const filePaths = entry.tracks.map( - (t) => t.FilePath, - ); + const filePaths = entry.tracks + .filter((t) => !t.Phantom) + .map((t) => t.FilePath); + + if (filePaths.length === 0) return; queueStore.setQueue(filePaths, 0); }; @@ -960,6 +1032,189 @@ export class PlaylistView 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( + '.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 // ================================================================= @@ -1022,13 +1277,30 @@ export class PlaylistView return html`

Playlists

- + + +
${this.creating @@ -1143,6 +1415,48 @@ export class PlaylistView ` : nothing} + + + ${this.playlistContextMenuOpen + ? html` +
+ + this.onPlaylistContextAction( + 'rename', + )} + > + + Rename + + + this.onPlaylistContextAction( + 'delete', + )} + > + + Delete Playlist + +
+ ` + : nothing} +
`; } @@ -1209,6 +1523,9 @@ export class PlaylistView const isDragOver = this.dragOverPlaylistIndex === index; + const isRenaming = + this.renamingPlaylistIndex === index; + return html`
  • this.handleToggle(index)} + @contextmenu=${(e: MouseEvent) => + this.handlePlaylistContextMenu( + e, + index, + )} > - - ${entry.summary.Name} - + ${isRenaming + ? html` + + e.stopPropagation()} + /> + ` + : html` + + ${entry.summary + .Name} + + `} ${countLabel} @@ -1285,9 +1631,15 @@ export class PlaylistView ${entry.tracks.map( (track, trackIndex) => { + const isPhantom = + track.Phantom; const active = - this.isActiveTrack(track); + !isPhantom && + this.isActiveTrack( + track, + ); const selected = + !isPhantom && this.activePlaylistIndex === playlistIndex && this.selection.isSelected( @@ -1297,7 +1649,12 @@ export class PlaylistView const classes = [ 'track-item', active ? 'active' : '', - selected ? 'selected' : '', + selected + ? 'selected' + : '', + isPhantom + ? 'phantom' + : '', ] .filter(Boolean) .join(' '); @@ -1305,48 +1662,68 @@ export class PlaylistView return html`
    - this.handleTrackClick( - e, - track, - trackIndex, - playlistIndex, - )} - @dblclick=${() => - this.handleTrackDblClick( - track, - trackIndex, - playlistIndex, - )} - @contextmenu=${( - e: MouseEvent, - ) => - this.handleTrackContextMenu( - e, - trackIndex, - playlistIndex, - )} - @dragstart=${( - e: DragEvent, - ) => - this.onTrackDragStart( - e, - track, - trackIndex, - playlistIndex, - )} - @dragend=${this - .onTrackDragEnd} + draggable=${isPhantom + ? 'false' + : 'true'} + @click=${isPhantom + ? nothing + : ( + e: MouseEvent, + ) => + this.handleTrackClick( + e, + track, + trackIndex, + playlistIndex, + )} + @dblclick=${isPhantom + ? nothing + : () => + this.handleTrackDblClick( + track, + trackIndex, + playlistIndex, + )} + @contextmenu=${isPhantom + ? nothing + : ( + e: MouseEvent, + ) => + this.handleTrackContextMenu( + e, + trackIndex, + playlistIndex, + )} + @dragstart=${isPhantom + ? nothing + : ( + e: DragEvent, + ) => + this.onTrackDragStart( + e, + track, + trackIndex, + playlistIndex, + )} + @dragend=${isPhantom + ? nothing + : this + .onTrackDragEnd} > + ${isPhantom + ? html`File not + found` + : nothing}
    `; }, diff --git a/frontend/src/events.ts b/frontend/src/events.ts index ce4c9da..f98f0be 100644 --- a/frontend/src/events.ts +++ b/frontend/src/events.ts @@ -40,6 +40,13 @@ export const Events = { RequestInsertTracksAtIndex: "RequestInsertTracksAtIndex", RequestMoveQueueTracks: "RequestMoveQueueTracks", + // Playlist events + PlaylistCreated: "PlaylistCreated", + PlaylistDeleted: "PlaylistDeleted", + PlaylistRenamed: "PlaylistRenamed", + PlaylistTracksChanged: "PlaylistTracksChanged", + PlaylistsRestored: "PlaylistsRestored", + // Library events LibraryScanStarted: "LibraryScanStarted", LibraryScanComplete: "LibraryScanComplete", diff --git a/frontend/src/store/playlist-store.ts b/frontend/src/store/playlist-store.ts index f82cf2d..a2e008e 100644 --- a/frontend/src/store/playlist-store.ts +++ b/frontend/src/store/playlist-store.ts @@ -15,6 +15,26 @@ class PlaylistStore { EventsOn(Events.LibraryScanComplete, () => { 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(); + }); } // =================================================================== diff --git a/frontend/wailsjs/go/frontendutil/FrontendUtil.d.ts b/frontend/wailsjs/go/frontendutil/FrontendUtil.d.ts index 29db0bc..04c3a8f 100755 --- a/frontend/wailsjs/go/frontendutil/FrontendUtil.d.ts +++ b/frontend/wailsjs/go/frontendutil/FrontendUtil.d.ts @@ -4,4 +4,6 @@ import {context} from '../models'; export function DirectoryPicker():Promise; +export function PlaylistFilePicker():Promise; + export function SetContext(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/frontendutil/FrontendUtil.js b/frontend/wailsjs/go/frontendutil/FrontendUtil.js index 3dc43f6..858ac2c 100755 --- a/frontend/wailsjs/go/frontendutil/FrontendUtil.js +++ b/frontend/wailsjs/go/frontendutil/FrontendUtil.js @@ -6,6 +6,10 @@ export function DirectoryPicker() { return window['go']['frontendutil']['FrontendUtil']['DirectoryPicker'](); } +export function PlaylistFilePicker() { + return window['go']['frontendutil']['FrontendUtil']['PlaylistFilePicker'](); +} + export function SetContext(arg1) { return window['go']['frontendutil']['FrontendUtil']['SetContext'](arg1); } diff --git a/frontend/wailsjs/go/library/Library.d.ts b/frontend/wailsjs/go/library/Library.d.ts index 9421659..54a0758 100755 --- a/frontend/wailsjs/go/library/Library.d.ts +++ b/frontend/wailsjs/go/library/Library.d.ts @@ -15,4 +15,6 @@ export function Scan():Promise; export function SetContext(arg1:context.Context):Promise; +export function SetPlaylistRestorer(arg1:library.playlistRestorer):Promise; + export function SetQueue(arg1:library.queueClearer):Promise; diff --git a/frontend/wailsjs/go/library/Library.js b/frontend/wailsjs/go/library/Library.js index cdcdfac..3302081 100755 --- a/frontend/wailsjs/go/library/Library.js +++ b/frontend/wailsjs/go/library/Library.js @@ -26,6 +26,10 @@ export function SetContext(arg1) { return window['go']['library']['Library']['SetContext'](arg1); } +export function SetPlaylistRestorer(arg1) { + return window['go']['library']['Library']['SetPlaylistRestorer'](arg1); +} + export function SetQueue(arg1) { return window['go']['library']['Library']['SetQueue'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 754591e..dac9178 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -425,6 +425,7 @@ export namespace playlist { CoverArtMedium: string; CoverArtLarge: string; Duration: string; + Phantom: boolean; static createFrom(source: any = {}) { return new Track(source); @@ -443,6 +444,7 @@ export namespace playlist { this.CoverArtMedium = source["CoverArtMedium"]; this.CoverArtLarge = source["CoverArtLarge"]; this.Duration = source["Duration"]; + this.Phantom = source["Phantom"]; } } export class WithTracks { diff --git a/frontend/wailsjs/go/playlist/Service.d.ts b/frontend/wailsjs/go/playlist/Service.d.ts index 06cc8aa..f432d23 100755 --- a/frontend/wailsjs/go/playlist/Service.d.ts +++ b/frontend/wailsjs/go/playlist/Service.d.ts @@ -9,12 +9,20 @@ export function CreatePlaylist(arg1:string):Promise; export function CreatePlaylistWithTracks(arg1:string,arg2:Array):Promise; +export function DeletePlaylist(arg1:number):Promise; + export function GetAllPlaylists():Promise>; export function GetAllPlaylistsWithTracks():Promise>; export function GetPlaylistTracks(arg1:number):Promise>; +export function ImportPlaylist(arg1:string):Promise; + export function RemoveTracksFromPlaylist(arg1:number,arg2:Array):Promise; +export function RenamePlaylist(arg1:number,arg2:string):Promise; + +export function RestoreAllPlaylists():Promise; + export function SetContext(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/playlist/Service.js b/frontend/wailsjs/go/playlist/Service.js index 7e01a45..b5dc496 100755 --- a/frontend/wailsjs/go/playlist/Service.js +++ b/frontend/wailsjs/go/playlist/Service.js @@ -14,6 +14,10 @@ export function 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() { return window['go']['playlist']['Service']['GetAllPlaylists'](); } @@ -26,10 +30,22 @@ export function 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) { 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) { return window['go']['playlist']['Service']['SetContext'](arg1); }