66#include < JuceHeader.h>
77#include < unordered_map>
88#include < unordered_set>
9+ #include < functional>
910#include < string>
1011
1112// ==============================================================================
@@ -120,6 +121,9 @@ struct TrackMapEntry
120121 juce::String timecodeOffset = " 00:00:00:00" ; // HH:MM:SS:FF
121122 juce::String notes;
122123
124+ // Sort order (0 = default/alphabetical, >0 = explicit position from playlist import)
125+ int sortOrder = 0 ;
126+
123127 // MIDI triggers (independent -- any combination can fire simultaneously)
124128 int midiChannel = 0 ; // 0-15 (displayed as 1-16), shared across all MIDI types
125129 int midiNoteNum = -1 ; // Note On: note number (-1 = disabled, 0-127)
@@ -182,6 +186,8 @@ struct TrackMapEntry
182186 obj->setProperty (" durationSec" , durationSec);
183187 obj->setProperty (" timecodeOffset" , timecodeOffset);
184188 obj->setProperty (" notes" , notes);
189+ if (sortOrder > 0 )
190+ obj->setProperty (" sortOrder" , sortOrder);
185191
186192 // MIDI triggers (independent)
187193 obj->setProperty (" midiChannel" , midiChannel);
@@ -239,6 +245,7 @@ struct TrackMapEntry
239245 title = getString (" title" );
240246 durationSec = juce::jmax (0 , getInt (" durationSec" , 0 ));
241247 notes = getString (" notes" );
248+ sortOrder = juce::jmax (0 , getInt (" sortOrder" , 0 ));
242249
243250 // Legacy migration: if entry has trackId but no title, generate a placeholder
244251 // so imported v1.5 entries don't vanish (user can edit them later).
@@ -498,6 +505,45 @@ class TrackMap
498505 // / Clear all entries
499506 void clear () { entries.clear (); ++generation; }
500507
508+ // / Apply playlist order: reorder existing tracks, add missing ones.
509+ // / Does NOT touch cues, triggers, offsets, or notes of existing entries.
510+ // / Tracks not in the playlist have their sortOrder reset to 0 (appear after playlist).
511+ void applyPlaylistOrder (const std::vector<TrackMapEntry>& playlist)
512+ {
513+ // Reset all existing sortOrders
514+ for (auto & [k, entry] : entries)
515+ entry.sortOrder = 0 ;
516+
517+ // Apply playlist positions: update sortOrder on existing, add new
518+ int pos = 1 ;
519+ for (auto & pe : playlist)
520+ {
521+ if (!pe.hasValidKey ()) continue ;
522+
523+ // Try exact key (artist|title|duration) first, then fallback
524+ // to artist|title only — duration from XML may differ from CDJ
525+ auto key = pe.key ();
526+ auto it = entries.find (key);
527+ if (it != entries.end ())
528+ {
529+ it->second .sortOrder = pos;
530+ }
531+ else if (auto * existing = findIgnoringDuration (pe.artist , pe.title ))
532+ {
533+ existing->sortOrder = pos;
534+ }
535+ else
536+ {
537+ // New entry — add with playlist position
538+ TrackMapEntry newEntry = pe;
539+ newEntry.sortOrder = pos;
540+ entries[key] = std::move (newEntry);
541+ }
542+ ++pos;
543+ }
544+ ++generation;
545+ }
546+
501547 // ------------------------------------------------------------------
502548 // Iteration & info
503549 // ------------------------------------------------------------------
@@ -529,8 +575,17 @@ class TrackMap
529575 for (auto & [k, entry] : entries)
530576 result.push_back (&entry);
531577
578+ // Sort by sortOrder first (0 = unordered, sorts after explicit positions).
579+ // Within the same sortOrder (or both 0), sort alphabetically by artist/title.
532580 std::sort (result.begin (), result.end (),
533581 [](const TrackMapEntry* a, const TrackMapEntry* b) {
582+ // Both have explicit order → compare by order
583+ if (a->sortOrder > 0 && b->sortOrder > 0 )
584+ return a->sortOrder < b->sortOrder ;
585+ // Only one has explicit order → it comes first
586+ if (a->sortOrder > 0 ) return true ;
587+ if (b->sortOrder > 0 ) return false ;
588+ // Neither has order → alphabetical
534589 int cmp = a->artist .compareIgnoreCase (b->artist );
535590 return cmp != 0 ? cmp < 0 : a->title .compareIgnoreCase (b->title ) < 0 ;
536591 });
@@ -597,6 +652,14 @@ class TrackMap
597652 // Artwork and waveform will be fetched from the CDJ on first play.
598653 // ------------------------------------------------------------------
599654 static std::vector<TrackMapEntry> parseRekordboxXml (const juce::File& file)
655+ {
656+ return parseRekordboxXml (file, " " );
657+ }
658+
659+ // / Parse rekordbox XML export. If playlistName is non-empty, only return
660+ // / tracks from that playlist in playlist order. Otherwise return all tracks.
661+ static std::vector<TrackMapEntry> parseRekordboxXml (const juce::File& file,
662+ const juce::String& playlistName)
600663 {
601664 std::vector<TrackMapEntry> result;
602665 auto xml = juce::XmlDocument::parse (file);
@@ -605,32 +668,148 @@ class TrackMap
605668 auto * collection = xml->getChildByName (" COLLECTION" );
606669 if (!collection) return result;
607670
608- // Deduplicate by key (same artist+title can appear from multiple
609- // locations, e.g. local library + USB export in the same XML).
610- std::unordered_set<std::string> seen;
611-
671+ // Build TrackID → entry map from COLLECTION
672+ std::unordered_map<int , TrackMapEntry> trackById;
612673 for (auto * track = collection->getChildByName (" TRACK" );
613674 track != nullptr ;
614675 track = track->getNextElementWithTagName (" TRACK" ))
615676 {
616677 juce::String title = track->getStringAttribute (" Name" ).trim ();
617678 juce::String artist = track->getStringAttribute (" Artist" ).trim ();
618679 int duration = track->getIntAttribute (" TotalTime" , 0 );
680+ int trackId = track->getIntAttribute (" TrackID" , 0 );
619681
620- if (title.isEmpty ()) continue ; // hasValidKey requires title
682+ if (title.isEmpty () || trackId <= 0 ) continue ;
621683
622684 TrackMapEntry e;
623685 e.title = title;
624686 e.artist = artist;
625687 e.durationSec = duration;
688+ trackById[trackId] = std::move (e);
689+ }
690+
691+ // If a playlist is requested, filter and order by playlist
692+ if (playlistName.isNotEmpty ())
693+ {
694+ auto * playlists = xml->getChildByName (" PLAYLISTS" );
695+ if (!playlists) return result;
696+
697+ // Traverse by full path (e.g. "Shows / Saturday") to handle
698+ // duplicate playlist names in different folders.
699+ // Path segments skip ROOT (same as listRekordboxPlaylists).
700+ juce::XmlElement* playlistNode = nullptr ;
701+ {
702+ // Split path into segments: "Shows / Saturday" → ["Shows", "Saturday"]
703+ juce::StringArray segments;
704+ if (playlistName.contains (" / " ))
705+ segments.addTokens (playlistName, " / " , " " );
706+ else
707+ segments.add (playlistName);
708+ // Remove empty tokens from split
709+ for (int i = segments.size () - 1 ; i >= 0 ; --i)
710+ if (segments[i].isEmpty ()) segments.remove (i);
711+
712+ // Start from ROOT node (first Type=0 child of PLAYLISTS)
713+ juce::XmlElement* current = nullptr ;
714+ for (auto * child = playlists->getFirstChildElement ();
715+ child != nullptr ; child = child->getNextElement ())
716+ {
717+ if (child->getTagName () == " NODE"
718+ && child->getIntAttribute (" Type" , -1 ) == 0 )
719+ { current = child; break ; }
720+ }
626721
627- auto k = e.key ();
722+ // Walk path segments: folders first, last segment is the playlist
723+ for (int si = 0 ; current != nullptr && si < segments.size (); ++si)
724+ {
725+ bool isLast = (si == segments.size () - 1 );
726+ int wantType = isLast ? 1 : 0 ; // 1=playlist, 0=folder
727+ juce::XmlElement* found = nullptr ;
728+ for (auto * child = current->getFirstChildElement ();
729+ child != nullptr ; child = child->getNextElement ())
730+ {
731+ if (child->getTagName () == " NODE"
732+ && child->getStringAttribute (" Name" ) == segments[si]
733+ && child->getIntAttribute (" Type" , -1 ) == wantType)
734+ { found = child; break ; }
735+ }
736+ current = found;
737+ }
738+ playlistNode = current;
739+ }
740+ if (!playlistNode) return result;
741+
742+ // Collect tracks in playlist order
743+ std::unordered_set<std::string> seen;
744+ for (auto * tr = playlistNode->getChildByName (" TRACK" );
745+ tr != nullptr ;
746+ tr = tr->getNextElementWithTagName (" TRACK" ))
747+ {
748+ int key = tr->getIntAttribute (" Key" , 0 );
749+ auto it = trackById.find (key);
750+ if (it != trackById.end ())
751+ {
752+ auto k = it->second .key ();
753+ if (!seen.count (k))
754+ {
755+ seen.insert (k);
756+ result.push_back (it->second );
757+ }
758+ }
759+ }
760+ return result;
761+ }
762+
763+ // No playlist specified — return all tracks from COLLECTION
764+ std::unordered_set<std::string> seen;
765+ for (auto & [id, entry] : trackById)
766+ {
767+ auto k = entry.key ();
628768 if (seen.count (k)) continue ;
629769 seen.insert (k);
630-
631- result.push_back (std::move (e));
770+ result.push_back (std::move (entry));
632771 }
772+ return result;
773+ }
633774
775+ // / List available playlist names in a rekordbox XML file
776+ static juce::StringArray listRekordboxPlaylists (const juce::File& file)
777+ {
778+ juce::StringArray result;
779+ auto xml = juce::XmlDocument::parse (file);
780+ if (!xml || xml->getTagName () != " DJ_PLAYLISTS" ) return result;
781+
782+ auto * playlists = xml->getChildByName (" PLAYLISTS" );
783+ if (!playlists) return result;
784+
785+ // Recursive scan for Type=1 (playlist) nodes with entries.
786+ // ROOT is always the top-level folder in rekordbox — skip it in the path.
787+ std::function<void (juce::XmlElement*, const juce::String&)> scan =
788+ [&](juce::XmlElement* node, const juce::String& path)
789+ {
790+ for (auto * child = node->getFirstChildElement ();
791+ child != nullptr ; child = child->getNextElement ())
792+ {
793+ if (child->getTagName () != " NODE" ) continue ;
794+ juce::String name = child->getStringAttribute (" Name" );
795+ int type = child->getIntAttribute (" Type" , -1 );
796+
797+ if (type == 1 ) // playlist
798+ {
799+ int entries = child->getIntAttribute (" Entries" , 0 );
800+ if (entries > 0 )
801+ result.add (path.isEmpty () ? name : path + " / " + name);
802+ }
803+ else if (type == 0 ) // folder
804+ {
805+ // Skip ROOT — it's always the top-level node in rekordbox XML
806+ juce::String childPath = name.equalsIgnoreCase (" ROOT" ) ? path
807+ : (path.isEmpty () ? name : path + " / " + name);
808+ scan (child, childPath);
809+ }
810+ }
811+ };
812+ scan (playlists, " " );
634813 return result;
635814 }
636815
0 commit comments