Sorting items on a (playout) playlist

At our station programs are mostly presented live, in assist mode, with voice content spoken into microphones in real time. Our programs have a human Selector who will bring vinyl, optical and USB-stored file media to the studio. Typically 2-3 hours of media files are dropped into a playlist from where they are ordered and played.

We have configured mAirList to retain played items on the list as an aid to back-announcing and generally noting what has and hasn’t been played. Operators use manual processes to maintain what’s on the list according to individual need.

When working in this manner it’s helpful to be able to sort the unplayed items on the list (played items are retained in the order in which they were played). Our user interface has buttons to sort the list, in ascending or descending order, according to filename, title and duration.

We have 6 simple scripts that are bound to each of the UI buttons. These invoke the core functionality provided by the following script. In our installation it’s called sort-common.mls. You’ll find examples of the invoking scripts towards the end of this article.

// A playlist sorting tool. What it sorts and the order of the sorted list
// are determined by a BYO comparison function.
//
// IMPORTANT: you must define a comparison function before including this file.
//
// The template for the function is:
//		function sortCompare(left, right : IPlaylistItem) : boolean;
//
// Your function should return True iff you want the left playlist item to be
// sorted before the right.
//
// There is only one entry point.:
//		procedure sortPlaylist(pl : IPlaylist);
//
// AMENDED:
// 07:56 26/04/2019 (cbp)
// Tidied up the documentation.
// 08:50 26/04/2019 (cbp)
// Added list locking and exception handling.
// 17:16 07/05/2019 (cbp)
// Improvements to list locking to guarantee it's unlocked should an
// exception occur elsewhere.

{$IFNDEF RRR_CONSTANTS_LOADED}
	{$Include C:\mAirListData\control\constants.mls}
{$ENDIF}
{$IFNDEF RRR_UTILITIES_LOADED}
	{$Include C:\mAirListData\control\utilities.mls}
{$ENDIF}

// This file contains two different implementations of quicksort. Initially I
// coded it around Nico Lumuto's partitioning scheme thinking it would perform
// better in a script. It proved to be unusable when handed a list that was
// substantially sorted. I then implemented Tony Hoare's partitioner and added
// this preprocessor selector so we can easily switch between them.
{$DEFINE HOARE}
{$IFNDEF HOARE}
{$DEFINE LUMOTO}
{$ENDIF}

{$IFDEF LUMOTO}
procedure invertPlaylist(pl : IPlaylist; lo : integer);
var
	hi : integer;
begin
	hi := pl.GetCount - 1;
	SystemLog(LOG_ENTRY_TAG + 'invertPlaylist(' + IntToStr(lo) + ',' + IntToStr(hi) + ')');
	while hi > lo do
	begin
		pl.Move(lo, hi);
		hi := hi - 1;
		pl.Move(hi, lo);
		lo := lo + 1;
	end;
	SystemLog(LOG_ENTRY_TAG + 'invertPlaylist - done');
end;

function lomutoPartition(pl : IPlaylist; lo, hi : integer) : integer;
var
	i, j, limit : integer;
	pivot : IPlaylistItem;
begin
//	SystemLog(LOG_ENTRY_TAG + 'lomutoPartition(' + IntToStr(lo) + ',' + IntToStr(hi) + ')');
	pivot := pl.GetItem(hi);
	i := lo;
	limit := hi - 1;
	for j := lo to limit do
	begin
		if sortCompare(pivot, pl.GetItem(j)) then
		begin
			pl.Move(j, i);
			i := i + 1;
		end;
	end;
	pl.Move(hi, i);
	Result := i;
end;

// An implementation of quicksort that uses Lumuto's partitioning algorithm
// (implemented above). It depends on a previously-defined comparison function
// (sortCompare).
//
// NOTE: this performs slower than Hoare when sorting real playlists. It is
// dramatically slower (as in 10s of seconds) when called with a list that is
// already sorted. This happens often in practical use.
procedure lumotoQuicksort(pl : IPlaylist; lo, hi : integer);
var
	p : integer;
begin
//	SystemLog(LOG_ENTRY_TAG + 'lumotoQuicksort(' + IntToStr(lo) + ',' + IntToStr(hi) + ')');
	if lo < hi then
	begin
		p := lomutoPartition(pl, lo, hi);
		lumotoQuicksort(pl, lo, p - 1);
		lumotoQuicksort(pl, p + 1, hi);
	end;
end;
{$ENDIF}

{$IFDEF HOARE}
procedure dumpList(pl : IPlaylist);
var limit, pox : integer;
begin
	limit := pl.GetCount - 1;
	for pox := 0 to limit do
		SystemLog(IntToStr(pox) + ': ' + pl.GetItem(pox).GetTitle);
end;

// An implementation of quicksort that uses Hoare's partitioning algorithm.
// It depends on a previously-defined comparison function (sortCompare).
//
// NOTE: this performs faster than Lumuto when sorting real playlists. It is
// dramatically faster when called with a list that is already sorted. This
// happens often in practical use.
procedure hoareQuicksort(pl : IPlaylist; lo, hi : integer);
var
	i, j : integer;
	pivot : IPlaylistItem;
begin
//	SystemLog(LOG_ENTRY_TAG + 'hoareQuicksort(' + IntToStr(lo) + ',' + IntToStr(hi) + ')');
	i := lo;
	j := hi;
	pivot := pl.GetItem((lo + hi) / 2);
	repeat
		while sortCompare(pivot, pl.GetItem(i)) do i := i + 1;
		while sortCompare(pl.GetItem(j), pivot) do j := j - 1;
		if i <= j then
		begin
//			SystemLog('swap(' + IntToStr(i) + ',' + IntToStr(j) + ')');
			if i <> j then
			begin
				try
					pl.BeginUpdate;
					pl.Move(j, i + 1);
					pl.Move(i, j);
				finally
					pl.EndUpdate;
				end;
			end;
			i := i + 1;
			j := j - 1;
		end;
	until i > j;
	if lo < j then hoareQuicksort(pl, lo, j);
	if i < hi then hoareQuicksort(pl, i, hi);
end;
{$ENDIF}

// This might do quite a bit more than its name implies. When it returns all
// played items will be at the top of the list. Their order will be unchanged.
// Any loaded items will follow the played items. The index of the first
// unplayed, unloaded item will then be returned.
function findFirstSortableRow(pl : IPlaylist) : integer;
var
	limit, loadPos, listPos, movePos, played, loaded : integer;
	item : IPlaylistItem;
	pis : TPlaylistItemState;
	pbc : IPlaybackControl;
	loadedPositions : array [0..9] of integer; // BUGCHECK: never say never.

begin
	Result := 0;
	limit := pl.GetCount - 1;
	if limit < 0 then exit;

	loaded := 0;
	played := 0;
	movePos := 0;
	pbc := IPlaybackControl(pl);

	try
		pl.BeginUpdate;
		for listPos := 0 to limit do
		begin
			item := pl.GetItem(listPos);
//			SystemLog(IntToStr(listPos) + ': ' + pl.GetItem(listPos).GetTitle);
			pis := pl.GetMetadata(listPos).GetState;

			// Move these items together at the top.
			if (pis = pisPlayed) OR (pis = pisSkipped) then
			begin
				movePos := played;
				played := played + 1;
				if listPos > movePos then
				begin
					SystemLog(LOG_ENTRY_TAG +
						'findFirstSortableRow - played move(' +
							IntToStr(listPos) + ',' + IntToStr(movePos) + ')');
					pl.Move(listPos, movePos);
				end;
			end
			else if pbc.GetPlayerOfItem(item) <> -1 then
				loaded := loaded + 1;
		end;

		if loaded > 0 then
		begin
			// Scan the list again and note the position of any loaded items.
			for listPos := 0 to limit do
			begin
				if pbc.GetPlayerOfItem(pl.GetItem(listPos)) <> -1 then
				begin
					SystemLog(LOG_ENTRY_TAG +
						'findFirstSortableRow - loaded item at ' + IntToStr(listPos));
					loadedPositions[loadPos] := listPos;
					loadPos := loadPos + 1;
					if loadPos = loaded then break; // No need to keep looking.
				end;
			end;
			limit := loadPos - 1;
			movePos := played;
			for loadPos := 0 to limit do
			begin
				listPos := loadedPositions[loadPos];
				SystemLog(LOG_ENTRY_TAG +
					'findFirstSortableRow - should move(' +
						IntToStr(listPos) + ',' + IntToStr(movePos) + ')?');
				if listPos <> movePos then
				begin
					if listPos > movePos then
					begin
						SystemLog(LOG_ENTRY_TAG +
							'findFirstSortableRow - loaded move(' +
								IntToStr(listPos) + ',' + IntToStr(movePos) + ')');
						pl.Move(listPos, movePos);
						movePos := movePos + 1;
					end
					else
						RaiseException(erCustomError,
							'INVARIANT VIOLATION - ' +
								IntToStr(listPos) + ' > ' + IntToStr(movePos));
				end
				else
					movePos := movePos + 1;
			end;
		end;
		Result := played + loaded;
	except
		SystemLogException('findFirstSortableRow');
		Result := -1;
	finally
		pl.EndUpdate;
		SystemLog(LOG_ENTRY_TAG + 'findFirstSortableRow - movePos == ' + IntToStr(movePos));
		SystemLog(LOG_ENTRY_TAG + 'findFirstSortableRow - played == ' + IntToStr(played));
		SystemLog(LOG_ENTRY_TAG + 'findFirstSortableRow - loaded == ' + IntToStr(loaded));
		SystemLog(LOG_ENTRY_TAG + 'findFirstSortableRow - Result == ' + IntToStr(Result));
	end;
end;

procedure sortPlaylist(pl : IPlaylist);
var
	start, finish : integer;
begin
	start := findFirstSortableRow(pl);
	finish := pl.GetCount - 1;
	if (start < 0) OR (start >= finish) then exit;
	SystemLog(LOG_ENTRY_TAG + 'sortPlaylist ' + IntToStr(start) + ' --> ' + IntToStr(finish));
{$IFDEF HOARE}
	SystemLog(LOG_ENTRY_TAG + 'sortPlaylist - using hoare');
	hoareQuicksort(pl, start, finish);
{$ENDIF}
{$IFDEF LUMOTO}
	SystemLog(LOG_ENTRY_TAG + 'sortPlaylist - using lumoto');
	lumotoQuicksort(pl, start, finish);
{$ENDIF}
	SystemLog(LOG_ENTRY_TAG + 'sortPlaylist - done');
end;

Here’s two of the scripts that call the above quicksort entry point.

--- playout-sort-fname-l2h.mls -----
// Button handler - sort Playout list by filename, A first

function sortCompare(l, r : IPlaylistItem) : boolean;
begin
	Result := lowercase(IFilePlaylistItem(l).GetFilename) > lowercase(IFilePlaylistItem(r).GetFilename);
end;

{$Include C:\mAirListData\control\sort-common.mls}

begin
	sortPlaylist(Playlist(PLAY_PLAYLIST_SCRIPT_IDX));
end.
--- playout-sort-fname-h2l.mls -----
// Button handler - sort Playout list by filename, Z first

function sortCompare(l, r : IPlaylistItem) : boolean;
begin
	Result := lowercase(IFilePlaylistItem(l).GetFilename) < lowercase(IFilePlaylistItem(r).GetFilename);
end;

{$Include C:\mAirListData\control\sort-common.mls}

begin
	sortPlaylist(Playlist(PLAY_PLAYLIST_SCRIPT_IDX));
end.

Caveat: All these scripts exist within a framework we’ve built to aid consistency, interoperability and maintainability (our current implementation employs around 40 scripts of varying complexity). Consequently the ones presented here may depend on external declarations and definitions. If you attempt to use the sort and encounter errors about undefined things, reply to this thread and I’ll supply our values for those things.

Caveat 2: the above code has been running on our (3) production mAirList installations 24x7 for about a year. While it’s never failed us, it doubtless has undetected flaws. YMMV.

Cameron