Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 10 additions & 16 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2749,30 +2744,29 @@ 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)
if err != nil {
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 {
Expand Down
31 changes: 31 additions & 0 deletions api/usbplaylists.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,37 @@ func (s *Server) usbPlaylists() []*PlaylistInfo {
return out
}

// exportPlaylistSource resolves a "playlist:<id>"/"smart:<id>" 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 {
Expand Down
34 changes: 34 additions & 0 deletions api/usbplaylists_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
5 changes: 3 additions & 2 deletions api/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9706,8 +9706,9 @@ <h4>TAGS <span class="count" id="tags-count">${drawerTrackTags.length}</span></h
<button id="col-add" title="Browse for folders or audio files to import into the library">+ ADD FILES</button>
<button id="pl-export">EXPORT…</button>`;
} 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 ? '' : `<button id="pl-export">EXPORT…</button>`;
} else if (isFolder) {
// Folder selected — only rename/delete makes sense (no tracks).
actions.innerHTML = `
Expand Down
62 changes: 62 additions & 0 deletions pdb/sanitize_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
62 changes: 55 additions & 7 deletions pdb/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"time"
"unicode/utf16"
"unicode/utf8"
)

const (
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading