diff --git a/api/api.go b/api/api.go index f488b27..c69e281 100644 --- a/api/api.go +++ b/api/api.go @@ -1560,16 +1560,11 @@ func (s *Server) handleExportPreview(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad source ID: "+err.Error(), http.StatusBadRequest) return } - if s.Playlists == nil { - http.Error(w, "playlist store not available", http.StatusServiceUnavailable) - return - } - pl := s.Playlists.Get(uint32(id)) - if pl == nil { + _, trackIDs, ok := s.exportPlaylistSource(uint32(id)) + if !ok { http.Error(w, "playlist not found", http.StatusNotFound) return } - trackIDs := s.Playlists.TracksFor(pl.ID, s.Library, s.Tags) for _, tid := range trackIDs { if t := s.Library.Track(tid); t != nil { tracks = append(tracks, t) @@ -2749,6 +2744,10 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { switch { case src == "all": sourceLabel = "COLLECTION" + // Resolve the full collection here rather than letting export.Run + // default it from opts.Library, so the response's track_count (and + // the web UI toast) reflect what was actually written instead of 0. + opts.Tracks = export.LibraryToTracks(s.Library) case strings.HasPrefix(src, "playlist:") || strings.HasPrefix(src, "smart:"): idStr := src[strings.IndexByte(src, ':')+1:] id, err := strconv.ParseUint(idStr, 10, 32) @@ -2756,23 +2755,18 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { http.Error(w, "source ID must be a uint32: "+err.Error(), http.StatusBadRequest) return } - if s.Playlists == nil { - http.Error(w, "playlist store not available", http.StatusServiceUnavailable) - return - } - pl := s.Playlists.Get(uint32(id)) - if pl == nil { + plName, trackIDs, ok := s.exportPlaylistSource(uint32(id)) + if !ok { http.Error(w, "playlist not found", http.StatusNotFound) return } - trackIDs := s.Playlists.TracksFor(pl.ID, s.Library, s.Tags) if len(trackIDs) == 0 { http.Error(w, "playlist has no tracks", http.StatusBadRequest) return } opts.Tracks = export.FilterTracks(export.LibraryToTracks(s.Library), trackIDs) - opts.Playlists = export.SinglePlaylist(pl.Name, trackIDs) - sourceLabel = pl.Name + opts.Playlists = export.SinglePlaylist(plName, trackIDs) + sourceLabel = plName case strings.HasPrefix(src, "selection:"): trackIDs, err := parseSelectionIDs(src[len("selection:"):]) if err != nil { diff --git a/api/usbplaylists.go b/api/usbplaylists.go index 31a3bad..c2f32d7 100644 --- a/api/usbplaylists.go +++ b/api/usbplaylists.go @@ -38,6 +38,37 @@ func (s *Server) usbPlaylists() []*PlaylistInfo { return out } +// exportPlaylistSource resolves a "playlist:"/"smart:" export +// source to its name and ordered track IDs — from the user playlist +// store, or, for namespaced IDs, from a served rekordbox USB's playlist +// tree. USB playlists are read-only for MUTATION, but export only reads +// tracks, so re-exporting a stick's playlist is allowed. +func (s *Server) exportPlaylistSource(id uint32) (string, []uint32, bool) { + if id&usbPlaylistIDBit != 0 { + ids := s.usbPlaylistTrackIDs(id) + if ids == nil { + return "", nil, false + } + name := "USB playlist" + raw := id &^ usbPlaylistIDBit + for _, n := range s.PDB.PlaylistTree { + if n.ID == raw { + name = n.Name + break + } + } + return name, ids, true + } + if s.Playlists == nil { + return "", nil, false + } + pl := s.Playlists.Get(id) + if pl == nil { + return "", nil, false + } + return pl.Name, s.Playlists.TracksFor(pl.ID, s.Library, s.Tags), true +} + // usbPlaylistTrackIDs resolves a namespaced USB playlist ID to its ordered // track IDs, or nil when it doesn't exist. func (s *Server) usbPlaylistTrackIDs(id uint32) []uint32 { diff --git a/api/usbplaylists_test.go b/api/usbplaylists_test.go new file mode 100644 index 0000000..cec3a62 --- /dev/null +++ b/api/usbplaylists_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package api + +import ( + "testing" + + "github.com/vynulldev/vynull/pdb" +) + +// TestExportPlaylistSourceUSB pins export resolution for a served +// rekordbox USB's playlists: the namespaced ID resolves to the stick's +// playlist name and track IDs (read-only applies to mutation, not +// export), and unknown IDs fail cleanly. +func TestExportPlaylistSourceUSB(t *testing.T) { + db := &pdb.Database{} + db.AddTrack(&pdb.Track{ID: 11, Title: "Alpha"}) + db.AddTrack(&pdb.Track{ID: 12, Title: "Beta"}) + db.PlaylistTree = []*pdb.FolderNode{ + {ID: 101, ParentID: 0, Name: "Warmup", TrackIDs: []uint32{11, 12}}, + } + s := &Server{PDB: db} + + name, ids, ok := s.exportPlaylistSource(usbPlaylistIDBit | 101) + if !ok || name != "Warmup" || len(ids) != 2 || ids[0] != 11 { + t.Fatalf("usb playlist resolution = %q %v %v", name, ids, ok) + } + if _, _, ok := s.exportPlaylistSource(usbPlaylistIDBit | 999); ok { + t.Fatal("unknown usb playlist id resolved") + } + if _, _, ok := s.exportPlaylistSource(5); ok { + t.Fatal("store id resolved with nil store") + } +} diff --git a/api/web/index.html b/api/web/index.html index 4c85415..fc1b637 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -9706,8 +9706,9 @@

TAGS ${drawerTrackTags.length}+ ADD FILES `; } else if (sel.read_only) { - // A served rekordbox USB's playlist — browse only, nothing to mutate. - actions.innerHTML = ''; + // A served rekordbox USB's playlist — no mutation, but EXPORT only + // reads tracks, so re-exporting a stick's playlist is fine. + actions.innerHTML = sel.is_folder ? '' : ``; } else if (isFolder) { // Folder selected — only rename/delete makes sense (no tracks). actions.innerHTML = ` diff --git a/pdb/sanitize_test.go b/pdb/sanitize_test.go new file mode 100644 index 0000000..4e06f3c --- /dev/null +++ b/pdb/sanitize_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package pdb + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" +) + +// TestSanitizeFilenameControlChars pins the fix for a real-library export +// failure: an album tag with an embedded NUL (a mangled apostrophe) made +// mkdir fail with EINVAL. Control characters are dropped, FAT-illegal +// punctuation is replaced, trailing dots/spaces are trimmed, and the +// result of sanitizing any input must be mkdir-able. +func TestSanitizeFilenameControlChars(t *testing.T) { + cases := map[string]string{ + "Album\x00Name (Remixes)": "AlbumName (Remixes)", + "Line\r\nBreak": "LineBreak", + "Tab\tName": "TabName", + "AC/DC: Back?": "AC_DC_ Back_", + "Trailing dot.": "Trailing dot", + "Trailing space ": "Trailing space", + "\x00\x01\x02": "_", + "normal name!!": "normal name!!", + } + dir := t.TempDir() + for in, want := range cases { + got := SanitizeFilename(in) + if got != want { + t.Errorf("SanitizeFilename(%q) = %q, want %q", in, got, want) + } + if err := os.Mkdir(filepath.Join(dir, got+"-"+got[:1]), 0o755); err != nil { + t.Errorf("sanitized name %q is not mkdir-able: %v", got, err) + } + } +} + +// TestTruncateComponent pins the directory-component cap used for artist and +// album names: byte-capped on a rune boundary, no ".ext" preservation +// (a dot in an artist name is just a dot), trailing space/dot re-trimmed, +// and never empty. Guards the export "file name too long" fix. +func TestTruncateComponent(t *testing.T) { + long := "A Long Artist Name. With A Dot That Goes On And On And On And On Past Sixty Four Characters" + got := truncateComponent(long, 64) + if len(got) > 64 { + t.Errorf("len = %d, want <= 64", len(got)) + } + if got != long[:64] { // no dot-as-extension mangling; plain prefix (ends on a letter here) + t.Errorf("got %q, want plain 64-byte prefix", got) + } + if truncateComponent("short", 64) != "short" { + t.Error("short names must pass through unchanged") + } + // A cut landing mid-rune backs up to a boundary (never produces invalid UTF-8). + multibyte := "Café " + strings.Repeat("ñ", 60) + if r := truncateComponent(multibyte, 20); !utf8.ValidString(r) { + t.Errorf("truncation produced invalid UTF-8: %q", r) + } +} diff --git a/pdb/writer.go b/pdb/writer.go index b6114cf..c480280 100644 --- a/pdb/writer.go +++ b/pdb/writer.go @@ -11,6 +11,7 @@ import ( "strings" "time" "unicode/utf16" + "unicode/utf8" ) const ( @@ -1237,11 +1238,49 @@ func le16put(b []byte, off int, v uint16) { // SanitizeFilename cleans a string for use in FAT32 paths. func SanitizeFilename(s string) string { - replacer := strings.NewReplacer( - "/", "_", "\\", "_", ":", "_", "*", "_", - "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_", - ) - return replacer.Replace(s) + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r < 0x20 || r == 0x7f: + // Control characters. Real tags carry these — a NUL from a + // mangled UTF-16 tag (a corrupt apostrophe in an album name) + // made mkdir fail with EINVAL on a real library — and FAT + // rejects them all. Dropped rather than replaced: they were + // never meant to be visible characters. + case strings.ContainsRune(`/\:*?"<>|`, r): + b.WriteRune('_') + default: + b.WriteRune(r) + } + } + // FAT also rejects names ending in a dot or space; and a name that + // sanitized away to nothing still has to be a usable directory. + out := strings.TrimRight(b.String(), " .") + if out == "" { + out = "_" + } + return out +} + +// truncateComponent caps a path component (a directory name like artist or +// album) to maxLen bytes on a UTF-8 rune boundary. Unlike TruncateFilename +// it does NOT preserve a trailing ".ext" — a dot in an artist/album name is +// just a dot, not an extension — and it re-trims trailing spaces/dots the +// cut may expose (FAT rejects those endings). +func truncateComponent(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + cut := maxLen + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + out := strings.TrimRight(s[:cut], " .") + if out == "" { + out = "_" + } + return out } // TruncateFilename shortens a filename to maxLen chars, preserving extension. @@ -1317,8 +1356,17 @@ func PrepareUSBLayout(tracks []*Track, srcDir, outDir string, copyFiles bool) er album = "Unknown" } - safeArtist := SanitizeFilename(artist) - safeAlbum := SanitizeFilename(album) + // Cap the artist and album directory components. A single component + // must stay under the filesystem limit (NAME_MAX 255 on Linux, lower + // on FAT), and rekordbox caps these names too. The export used to + // overflow with "file name too long" on a stick whose pdb decoded a + // corrupt artist tag into a 650-byte string (many names concatenated + // by a string-heap over-read — see the pdb reader); capping here + // keeps a malformed tag from ever producing an un-mkdir-able path, + // independent of the decode bug. 64 leaves ample headroom under the + // 126-char total-path budget below. + safeArtist := truncateComponent(SanitizeFilename(artist), 64) + safeAlbum := truncateComponent(SanitizeFilename(album), 64) ext := filepath.Ext(t.FilePath) baseName := strings.TrimSuffix(filepath.Base(t.FilePath), ext)