Log
·

A Chronological "Complete Works" Playlist, One Keyboard Maestro Hotkey Away

When I get into an artist, I want to hear the whole catalogue in release order — not shuffled, not whatever a “essentials” playlist decided mattered. Music.app doesn't have that as a smart-playlist rule, so I built one: a Keyboard Maestro macro that takes an artist name and hands back a complete, chronologically-ordered playlist. Rerun it after buying a new album and it just appends. It's pure leisure-project scope, but it turned into a genuinely useful lesson in where AppleScript quietly lies to you.

What it actually does

  • Triggered by a Keyboard Maestro macro that supplies an artist name via the PLAYLIST_ARTIST variable (or prompts for one if it's empty)
  • Orders the playlist by album release year, oldest to newest — tracks within an album stay in disc/track-number order
  • Idempotent: existing tracks are matched by persistent ID, so rerunning never duplicates anything
  • Filters out Digital Booklet PDFs, music videos, and — optionally — anything that isn't actually purchased, so Apple Music streaming filler doesn't sneak in
  • Self-heals: prunes tracks that no longer qualify, and rebuilds the order if a year tag gets corrected later

The chronology problem: compilation albums lie about their dates

Sorting tracks by their individual year tag seems obvious until you hit a covers or compilation record. Metallica's “Garage Inc.” is the reference case: its tracks carry the ORIGINAL songs' release years, not 1998, so a track-level sort smears the album across three decades of the timeline instead of placing it where it actually came out. The fix is to sort by album, not by track — each album placed by the MAXIMUM year found on any of its tracks.

	set albNames to {}
	set albYears to {}
	repeat with i in keepIdx
		set a to item i of theAlbums
		set y to my numOr(item i of theYears, 0)
		set foundAt to 0
		repeat with j from 1 to (count of albNames)
			if item j of albNames is a then
				set foundAt to j
				exit repeat
			end if
		end repeat
		if foundAt is 0 then
			set end of albNames to a
			set end of albYears to y
		else if y > (item foundAt of albYears) then
			set item foundAt of albYears to y
		end if

One more wrinkle: an album where every track is untagged has a max-year of 0, which would otherwise sort it before every real release. Untagged doesn't mean “year zero” — it usually means “recently added and not tagged yet” — so it gets pushed to the end instead:

on sortYear(theYear)
	if theYear ≤ 0 then return 999999
	return theYear
end sortYear

Two AppleScript foot-guns that ate an afternoon

The whose clause doesn't like being reused. My first instinct was to resolve the filtered track list once, store it in a variable, then pull each property off that. That fails with error -1728: assigning a whose result to a variable resolves it to a list of references, and a bulk property fetch on a list of references doesn't work the way it does on a live query. The fix is almost insultingly simple — repeat the identical whose clause inline for every property fetch. Ugly, but the queries run back to back against an unchanged library, so their result ordering matches, and the counts get asserted right after to make sure.

Enums lie once you leave the tell block, and lie differently depending on how you loop. cloud status and media kind are enums (purchased / subscription / uploaded, song / music video, etc.). Coerce one to text outside a tell application "Music" block in a compiled .scpt, and you don't get "subscription" — you get "«constant ****kSub»", because the app's terminology dictionary isn't loaded at runtime outside a tell. The comparison silently fails and every track looks unpurchased. The second bite: even resolved correctly, comparing enums inside a repeat with x in list loop is always false, because that loop form binds a reference to the list item, not the value — and a reference never equals an enum constant. Switching to indexed loops (repeat with i from 1 to (count of ...)) fixed it. Both failures are silent. No error, no crash — just a playlist that quietly excludes everything.

		-- Resolve the two enum properties to booleans HERE, inside the tell block.
		-- This must not be done by coercing to text further down: in a COMPILED
		-- .scpt the app's terminology is not loaded at runtime outside a tell, so
		-- `cloud status as text` yields "«constant ****kSub»" rather than
		-- "subscription" and every comparison silently fails. Enum-to-enum
		-- comparison compiles to a raw value check and needs no dictionary, so it
		-- behaves identically as source text and as a compiled script.
		-- Indexed loops, not `repeat with x in list`: the latter binds a REFERENCE
		-- to the list item, and comparing a reference against an enum constant is
		-- always false (silently — every track would look like a non-song).
		set isSongFlags to {}
		repeat with mi from 1 to (count of theMedia)
			set end of isSongFlags to ((item mi of theMedia) is song)
		end repeat
		set isPurchasedFlags to {}
		repeat with ci from 1 to (count of theCloud)
			set end of isPurchasedFlags to ((item ci of theCloud) is purchased)
		end repeat

Keeping it idempotent

Rerunning the macro on an artist you already have a playlist for doesn't rebuild from scratch — it reconciles. Existing tracks are matched against the desired list by persistent ID, which stays stable across restarts and library rebuilds:

  • Nothing missing, nothing out of order — reports “up to date” and touches nothing
  • New releases only — appends them, since anything new sorts after everything already present
  • New tracks that belong earlier in the timeline, or a corrected year tag that shifts an album's position — can't be handled by appending without breaking the chronology, so the playlist is rebuilt in place instead

It also prunes tracks that no longer qualify — say, a streaming track that was in there before purchasedOnly got switched on — and warns before a rebuild would discard anything added to the playlist by hand, since the library itself is never touched, only the playlist's membership.

Wiring it into Keyboard Maestro

The macro itself is nothing fancy: a hotkey prompts for an artist name, stores it in the KM variable `PLAYLIST_ARTIST`, and runs this script. The script reads that variable via the Keyboard Maestro Engine's `getvariable`, falls back to its own dialog if KM isn't running at all, and reports back through `PLAYLIST_RESULT` plus a system notification — so a rerun tells you at a glance whether anything actually changed.

The whole script

-- Creates or UPDATES a playlist of every track by one artist, albums oldest ->
-- newest and tracks in album order within each album.
--
-- Rerunning is idempotent: existing tracks are matched by `persistent ID` (stable
-- across restarts and library rebuilds, and identical between a library track and
-- its playlist copy — verified). Nothing is ever added twice.
--   * nothing new            -> reports "aktuell", touches nothing
--   * new releases only      -> appends them, order preserved
--   * back-catalogue additions that belong EARLIER in the timeline cannot be
--     appended without breaking the chronology, so the playlist contents are
--     rebuilt in place (see reorderOnDrift)
--
-- Artist comes from the Keyboard Maestro variable PLAYLIST_ARTIST; if unset (or KM
-- isn't running) the script prompts. Playlist name comes from PLAYLIST_NAME,
-- defaulting to "All <artist>".
--
-- Ordering is per ALBUM, not per track. Covers/compilation records (Metallica's
-- "Garage Inc." is the reference case) carry the ORIGINAL songs' years on their
-- tracks, so a track-year sort smears such an album across the timeline. Each album
-- is placed by the MAXIMUM year found on its tracks, which also absorbs the tracks
-- that carry year 0.
--
-- Filtering (all switchable via the properties below):
--   * booklets  — "Digital Booklet" liner-note PDFs; they sit at track 1 and would
--                 open every album. Matched by name and by "PDF" in `kind`.
--   * videos    — tour trailers and music videos, via the `media kind` enum.
--   * purchased — only tracks bought from the Store, via the `cloud status` enum.
--                 Excludes Apple Music streaming ("subscription") and also your own
--                 uploaded/matched files, which are owned but not purchased.
-- `media kind` and `cloud status` are enums, so neither needs localized string
-- matching; `kind` is only consulted for the locale-proof acronym "PDF".

use scripting additions

property excludeBooklets : true
property excludeVideos : true

-- Only include tracks actually bought from the Store. `cloud status` is the
-- authoritative, locale-independent flag: "purchased" vs "subscription" (Apple
-- Music streaming) vs "uploaded"/"matched" (your own files in iCloud Music
-- Library) vs "removed"/"no longer available". Note this also excludes uploaded
-- and matched tracks — those are owned, but not purchased.
property purchasedOnly : true

-- The playlist is script-managed: tracks that no longer qualify (streaming tracks
-- once purchasedOnly was switched on, booklets, tracks removed from the library)
-- are taken out on the next run. Set false to leave anything already in the
-- playlist alone, including manual additions.
property pruneUnwanted : true

-- When new tracks belong earlier in the timeline than tracks already present,
-- appending would break the chronology. true = rebuild the playlist contents in
-- place; false = append anyway and leave it slightly out of order.
property reorderOnDrift : true

-- Keyboard Maestro variable names
property kArtistVar : "PLAYLIST_ARTIST"
property kNameVar : "PLAYLIST_NAME"
property kResultVar : "PLAYLIST_RESULT"

on kmVariable(theName)
	try
		tell application "Keyboard Maestro Engine" to return getvariable theName
	on error
		return ""
	end try
end kmVariable

on setKmVariable(theName, theValue)
	try
		tell application "Keyboard Maestro Engine" to setvariable theName to theValue
	end try
end setKmVariable

-- Sort key for an album's year. An album whose tracks are all untagged has a
-- max-year of 0, which would otherwise place it before every real release; it is
-- pushed to the end instead. Only affects ORDERING — the real year is still what
-- gets reported.
on sortYear(theYear)
	if theYear ≤ 0 then return 999999
	return theYear
end sortYear

on numOr(theValue, theFallback)
	try
		if theValue is missing value then return theFallback
		return theValue as integer
	on error
		return theFallback
	end try
end numOr

on finish(theSummary)
	my setKmVariable(kResultVar, theSummary)
	display notification theSummary with title "Playlist"
	return theSummary
end finish

on run
	-- 1. resolve inputs -----------------------------------------------------
	set theArtist to my kmVariable(kArtistVar)
	if theArtist is missing value then set theArtist to ""
	if theArtist is "" then
		set theArtist to text returned of (display dialog ¬
			¬
				"Von welchem Interpreten soll die Playlist erstellt werden?" default answer "" with title ¬
			"Playlist nach Interpret" buttons {"Abbrechen", "Weiter"} default button "Weiter" cancel button "Abbrechen")
	end if
	if theArtist is "" then return "Kein Interpret angegeben."

	set theName to my kmVariable(kNameVar)
	if theName is missing value then set theName to ""
	if theName is "" then set theName to "All " & theArtist

	-- 2. collect matching library tracks ------------------------------------
	-- The `whose` clause is repeated inline for every fetch on purpose: assigning
	-- it to a variable first resolves it to a plain list of references, and a bulk
	-- property fetch on that list fails with -1728. The queries are identical and
	-- run back to back, so their ordering matches; counts are asserted below.
	tell application "Music"
		tell library playlist 1
			set theAlbums to album of (every track whose (artist contains theArtist or album artist contains theArtist) and album is not "")
			set theYears to year of (every track whose (artist contains theArtist or album artist contains theArtist) and album is not "")
			set theDiscs to disc number of (every track whose (artist contains theArtist or album artist contains theArtist) and album is not "")
			set theNumbers to track number of (every track whose (artist contains theArtist or album artist contains theArtist) and album is not "")
			set theNames to name of (every track whose (artist contains theArtist or album artist contains theArtist) and album is not "")
			set theKinds to kind of (every track whose (artist contains theArtist or album artist contains theArtist) and album is not "")
			set theMedia to media kind of (every track whose (artist contains theArtist or album artist contains theArtist) and album is not "")
			set theCloud to cloud status of (every track whose (artist contains theArtist or album artist contains theArtist) and album is not "")
			set thePIDs to persistent ID of (every track whose (artist contains theArtist or album artist contains theArtist) and album is not "")
			set theTracks to (every track whose (artist contains theArtist or album artist contains theArtist) and album is not "")
		end tell

		-- Resolve the two enum properties to booleans HERE, inside the tell block.
		-- This must not be done by coercing to text further down: in a COMPILED
		-- .scpt the app's terminology is not loaded at runtime outside a tell, so
		-- `cloud status as text` yields "«constant ****kSub»" rather than
		-- "subscription" and every comparison silently fails. Enum-to-enum
		-- comparison compiles to a raw value check and needs no dictionary, so it
		-- behaves identically as source text and as a compiled script.
		-- Indexed loops, not `repeat with x in list`: the latter binds a REFERENCE
		-- to the list item, and comparing a reference against an enum constant is
		-- always false (silently — every track would look like a non-song).
		set isSongFlags to {}
		repeat with mi from 1 to (count of theMedia)
			set end of isSongFlags to ((item mi of theMedia) is song)
		end repeat
		set isPurchasedFlags to {}
		repeat with ci from 1 to (count of theCloud)
			set end of isPurchasedFlags to ((item ci of theCloud) is purchased)
		end repeat
	end tell

	set trackCount to count of theTracks
	if trackCount is 0 then
		display alert "Keine Titel gefunden" message ("Keine Titel für: " & theArtist) as warning
		return "0 Titel für " & theArtist
	end if
	repeat with lst in {theAlbums, theYears, theDiscs, theNumbers, theNames, theKinds, theMedia, theCloud, thePIDs}
		if (count of lst) is not trackCount then error "Query-Ergebnisse inkonsistent."
	end repeat

	-- 3. filter out booklets / videos / non-purchased ------------------------
	set keepIdx to {}
	set skipped to 0
	set notPurchased to 0
	repeat with i from 1 to trackCount
		set isCruft to false

		-- Liner-note PDFs. They sit at track 1 and would open every album. Matched
		-- on the name and on "PDF" in the kind string ("PDF-Dokument" / "PDF
		-- document" — the acronym survives localization).
		if excludeBooklets then
			if ((item i of theNames) contains "Digital Booklet") then set isCruft to true
			if ((item i of theKinds) contains "PDF") then set isCruft to true
		end if

		-- Videos and purchased-state come from enum flags resolved inside the tell
		-- block above (see the note there); do NOT inline them as text comparisons.
		if excludeVideos and (not (item i of isSongFlags)) then set isCruft to true

		if isCruft then
			set skipped to skipped + 1
		else if purchasedOnly and (not (item i of isPurchasedFlags)) then
			set notPurchased to notPurchased + 1
		else
			set end of keepIdx to i
		end if
	end repeat
	if (count of keepIdx) is 0 then
		display alert "Nichts zu tun" message ("Keine gekauften Titel für: " & theArtist) as warning
		return "Keine gekauften Titel für " & theArtist
	end if

	-- 4. unique albums, each carrying the maximum year on its tracks ---------
	set albNames to {}
	set albYears to {}
	repeat with i in keepIdx
		set a to item i of theAlbums
		set y to my numOr(item i of theYears, 0)
		set foundAt to 0
		repeat with j from 1 to (count of albNames)
			if item j of albNames is a then
				set foundAt to j
				exit repeat
			end if
		end repeat
		if foundAt is 0 then
			set end of albNames to a
			set end of albYears to y
		else if y > (item foundAt of albYears) then
			set item foundAt of albYears to y
		end if
	end repeat

	-- 5. order albums by year, then name ------------------------------------
	-- Albums where NO track carries a year sort to the END rather than the front:
	-- year 0 means "untagged", not "released in year zero", and untagged material
	-- is typically a recent addition. See sortYear().
	set albOrder to {}
	repeat with j from 1 to (count of albNames)
		set end of albOrder to j
	end repeat
	repeat with x from 2 to (count of albOrder)
		set keyIdx to item x of albOrder
		set keyYear to my sortYear(item keyIdx of albYears)
		set keyName to item keyIdx of albNames
		set z to x - 1
		repeat while z ≥ 1
			set cmpIdx to item z of albOrder
			set cmpYear to my sortYear(item cmpIdx of albYears)
			if (cmpYear > keyYear) or ((cmpYear = keyYear) and ((item cmpIdx of albNames) > keyName)) then
				set item (z + 1) of albOrder to cmpIdx
				set z to z - 1
			else
				exit repeat
			end if
		end repeat
		set item (z + 1) of albOrder to keyIdx
	end repeat

	-- 6. flatten into the desired track order --------------------------------
	set desiredIdx to {}
	repeat with o from 1 to (count of albOrder)
		set aName to item (item o of albOrder) of albNames

		set memberIdx to {}
		repeat with i in keepIdx
			if item i of theAlbums is aName then set end of memberIdx to (i as integer)
		end repeat

		repeat with x from 2 to (count of memberIdx)
			set keyIdx to item x of memberIdx
			set keyKey to (my numOr(item keyIdx of theDiscs, 1)) * 10000 + (my numOr(item keyIdx of theNumbers, 0))
			set z to x - 1
			repeat while z ≥ 1
				set cmpIdx to item z of memberIdx
				set cmpKey to (my numOr(item cmpIdx of theDiscs, 1)) * 10000 + (my numOr(item cmpIdx of theNumbers, 0))
				if cmpKey > keyKey then
					set item (z + 1) of memberIdx to cmpIdx
					set z to z - 1
				else
					exit repeat
				end if
			end repeat
			set item (z + 1) of memberIdx to keyIdx
		end repeat

		repeat with i in memberIdx
			set end of desiredIdx to (i as integer)
		end repeat
	end repeat

	set desiredPIDs to {}
	repeat with i in desiredIdx
		set end of desiredPIDs to ((item i of thePIDs) as text)
	end repeat
	set desiredCount to count of desiredIdx

	-- 7. create the playlist if it doesn't exist yet --------------------------
	tell application "Music"
		set playlistExists to (exists user playlist theName)
	end tell

	if not playlistExists then
		tell application "Music" to set thePlaylist to (make new user playlist with properties {name:theName})
		set addedCount to my addTracks(thePlaylist, desiredIdx, theTracks)
		return my finish(theName & " erstellt: " & (addedCount as text) & " Titel, " & (count of albOrder) & " Alben")
	end if

	-- 8. otherwise reconcile against what is already in there ------------------
	tell application "Music"
		set thePlaylist to user playlist theName
		set existingRaw to persistent ID of every track of thePlaylist
	end tell
	set existingPIDs to {}
	repeat with x in existingRaw
		set end of existingPIDs to (x as text)
	end repeat

	-- positions (within desiredIdx) of tracks not yet in the playlist
	set missingPos to {}
	repeat with k from 1 to desiredCount
		if existingPIDs does not contain (item k of desiredPIDs) then set end of missingPos to k
	end repeat

	-- Tracks in the playlist that this script would not add: streaming tracks once
	-- purchasedOnly is on, booklets, tracks dropped from the library, or manual
	-- additions. With pruneUnwanted they are removed here (in REVERSE index order —
	-- deleting shifts every later index down), so the reconciliation below sees a
	-- clean slate. `delete track of playlist` unlinks only; the library is untouched.
	set prunedCount to 0
	if pruneUnwanted then
		tell application "Music"
			set n to count of tracks of thePlaylist
			repeat with i from n to 1 by -1
				if desiredPIDs does not contain ((persistent ID of track i of thePlaylist) as text) then
					delete track i of thePlaylist
					set prunedCount to prunedCount + 1
				end if
			end repeat
			if prunedCount > 0 then set existingRaw to persistent ID of every track of thePlaylist
		end tell
		if prunedCount > 0 then
			set existingPIDs to {}
			repeat with x in existingRaw
				set end of existingPIDs to (x as text)
			end repeat
			-- recompute what is missing now that the playlist has been pruned
			set missingPos to {}
			repeat with k from 1 to desiredCount
				if existingPIDs does not contain (item k of desiredPIDs) then set end of missingPos to k
			end repeat
		end if
	end if

	set extraCount to 0
	repeat with x in existingPIDs
		if desiredPIDs does not contain (x as text) then set extraCount to extraCount + 1
	end repeat

	-- ORDER drift. Membership alone is not enough: correcting a track's year tag
	-- changes where its album belongs without adding or removing anything, and the
	-- playlist would silently keep its stale order. Compare the relative order of
	-- the tracks common to both sides.
	set existingCommon to {}
	repeat with x in existingPIDs
		if desiredPIDs contains (x as text) then set end of existingCommon to (x as text)
	end repeat
	set desiredCommon to {}
	repeat with k from 1 to desiredCount
		if existingPIDs contains (item k of desiredPIDs) then set end of desiredCommon to (item k of desiredPIDs)
	end repeat

	set orderDrift to false
	if (count of existingCommon) is not (count of desiredCommon) then
		set orderDrift to true
	else
		repeat with k from 1 to (count of existingCommon)
			if (item k of existingCommon) is not (item k of desiredCommon) then
				set orderDrift to true
				exit repeat
			end if
		end repeat
	end if

	if ((count of missingPos) is 0) and (not orderDrift) then
		if prunedCount > 0 then
			set msg to theName & ": " & (prunedCount as text) & " Titel entfernt, jetzt " & (desiredCount as text)
		else
			set msg to theName & " ist aktuell: " & (desiredCount as text) & " Titel, nichts hinzugefügt"
		end if
		if extraCount > 0 then set msg to msg & " (" & (extraCount as text) & " fremde Titel belassen)"
		return my finish(msg)
	end if

	if ((count of missingPos) is 0) and (not reorderOnDrift) then
		return my finish(theName & ": Reihenfolge veraltet, aber reorderOnDrift ist aus — nichts geändert")
	end if

	-- Appending only preserves the chronology when every new track sorts after
	-- every track already present AND the existing order is still correct.
	set maxPresentPos to 0
	repeat with k from 1 to desiredCount
		if existingPIDs contains (item k of desiredPIDs) then set maxPresentPos to k
	end repeat
	set canAppend to false
	if (count of missingPos) > 0 then
		set canAppend to (((item 1 of missingPos) > maxPresentPos) and (not orderDrift))
	end if

	if canAppend or ((not reorderOnDrift) and ((count of missingPos) > 0)) then
		-- missingPos holds positions within desiredIdx; map them to the
		-- corresponding indices into theTracks, still in desired order.
		set toAdd to {}
		repeat with k in missingPos
			set end of toAdd to (item (k as integer) of desiredIdx)
		end repeat
		set addedCount to my addTracks(thePlaylist, toAdd, theTracks)
		set msg to theName & ": " & (addedCount as text) & " neue Titel angehängt (jetzt " & (desiredCount + extraCount as text) & ")"
		if not canAppend then set msg to msg & " — Reihenfolge nicht chronologisch"
		return my finish(msg)
	end if

	-- Rebuild required, either because new tracks belong earlier in the timeline or
	-- because the existing order no longer matches (e.g. a year tag was corrected).
	if (count of missingPos) > 0 then
		set theReason to (count of missingPos) as text
		set theReason to theReason & " neue Titel gehören chronologisch vor vorhandene."
	else
		set theReason to "Die Reihenfolge stimmt nicht mehr (z. B. korrigierte Jahresangaben)."
	end if

	-- Warn first if it would discard manual additions.
	if extraCount > 0 then
		set theChoice to button returned of (display alert ¬
			("Neuaufbau von \"" & theName & "\" nötig") ¬
				message theReason & " Beim Neuaufbau gehen " & (extraCount as text) & ¬
			¬
				" manuell hinzugefügte Titel verloren. Die Titel bleiben in der Mediathek." as warning buttons {"Abbrechen", "Neu aufbauen"} default button "Abbrechen")
		if theChoice is not "Neu aufbauen" then return "Abgebrochen."
	end if

	tell application "Music" to delete every track of thePlaylist
	set addedCount to my addTracks(thePlaylist, desiredIdx, theTracks)
	set msg to theName & " neu aufgebaut: " & (addedCount as text) & " Titel, " & ¬
		(count of albOrder) & " Alben"
	if (count of missingPos) > 0 then
		set msg to msg & " (" & ((count of missingPos) as text) & " neu)"
	else
		set msg to msg & " (nur neu sortiert)"
	end if
	if prunedCount > 0 then
		set msg to msg & ", " & (prunedCount as text) & " entfernt"
	end if
	return my finish(msg)
end run

-- Adds tracks (given as indices into theTracks) to thePlaylist, in the order given.
on addTracks(thePlaylist, theIndices, theTracks)
	set addedCount to 0
	repeat with i in theIndices
		try
			tell application "Music" to duplicate (item (i as integer) of theTracks) to thePlaylist
			set addedCount to addedCount + 1
		end try
	end repeat
	return addedCount
end addTracks

Type an artist, wait a second, hit play from track one. Small tool, but it's exactly the kind of thing Keyboard Maestro is for — turning a fiddly, repeatable chore into a single keystroke.

X Bluesky Hacker News