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_ARTISTvariable (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.