├── .editorconfig ├── .gitignore ├── .python-version ├── LICENSE.txt ├── Play Song (Alfred 3).alfredworkflow ├── Play Song (Alfred 4).alfredworkflow ├── Play Song (Alfred 5).alfredworkflow ├── README.md ├── actions ├── clearcache.applescript ├── clearqueue.applescript ├── getsearchquery.applescript ├── play-direct.applescript ├── play.applescript ├── playqueue.applescript ├── queue.applescript ├── shuffleoff.applescript ├── shuffleon.applescript └── shuffletoggle.applescript ├── filters ├── playalbum.applescript ├── playalbumby.applescript ├── playartist.applescript ├── playgenre.applescript ├── playplaylist.applescript ├── playsong.applescript ├── playsongby.applescript └── playsongin.applescript ├── icon.png ├── packager.json ├── requirements.txt ├── resources ├── compile-config.sh ├── config.applescript ├── get-song-artwork-path.sh └── icon-noartwork.png └── screenshot.png /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig (http://editorconfig.org) 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | indent_style = tab 11 | 12 | [*.py] 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.scpt 3 | .virtualenv/ 4 | nosetests.xml 5 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2025 Caleb Evans 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Play Song (Alfred 3).alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/play-song/90e3bb89eda91ccd30aab866b9b30ad38a624a5c/Play Song (Alfred 3).alfredworkflow -------------------------------------------------------------------------------- /Play Song (Alfred 4).alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/play-song/90e3bb89eda91ccd30aab866b9b30ad38a624a5c/Play Song (Alfred 4).alfredworkflow -------------------------------------------------------------------------------- /Play Song (Alfred 5).alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/play-song/90e3bb89eda91ccd30aab866b9b30ad38a624a5c/Play Song (Alfred 5).alfredworkflow -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Play Song 2 | 3 | *Copyright 2013-2025 Caleb Evans* 4 | *Released under the MIT license* 5 | 6 | Play Song is an [Alfred](https://www.alfredapp.com/) workflow designed to make 7 | playing music in Music.app extremely quick and convenient. 8 | 9 | The workflow will be solely supporting Alfred 5 going forward, but the workflow 10 | versions last compatible with the previous versions of Alfred will remain 11 | available here for your convenience. 12 | 13 | ![Play Song in action](screenshot.png) 14 | 15 | Special thanks to [@Tyilo](https://github.com/Tyilo) for his invaluable feedback 16 | and code contributions. 17 | 18 | ## Usage 19 | 20 | Play Song includes a number of keyword filters which allow you to search for and 21 | play songs in your Music.app library. For example: 22 | 23 | ### Playing a song 24 | 25 | ``` 26 | playsong hey jude 27 | ``` 28 | 29 | #### Playing a song in a particular album 30 | 31 | ``` 32 | playsongin abbey road 33 | ``` 34 | 35 | #### Playing a song by a particular artist 36 | 37 | ``` 38 | playsongby beatles 39 | ``` 40 | 41 | ### Playing an album 42 | 43 | ``` 44 | playalbum pet sounds 45 | ``` 46 | 47 | #### Playing an album by a particular artist 48 | 49 | ``` 50 | playalbumby beach boys 51 | ``` 52 | 53 | ### Playing an artist 54 | 55 | ``` 56 | playartist killers 57 | ``` 58 | 59 | ### Playing a genre 60 | 61 | ``` 62 | playgenre alternative 63 | ``` 64 | 65 | ### Playing a playlist 66 | 67 | ``` 68 | playplaylist favorites 69 | ``` 70 | 71 | ### Controlling the Music app's global shuffle setting 72 | 73 | ``` 74 | shuffleon 75 | ``` 76 | 77 | ``` 78 | shuffleoff 79 | ``` 80 | 81 | ``` 82 | shuffletoggle 83 | ``` 84 | 85 | ### Queueing songs 86 | 87 | For any of the above filters, choosing a result with the `cmd` key held down 88 | will queue the result (as opposed to playing it immediately). This allows you to 89 | queue up multiple songs before playing them. 90 | 91 | To play the songs you've queued, use the `playqueue` keyword. To clear the queue 92 | of all songs, use the `clearqueue` keyword. 93 | 94 | **Note:** At this time, Play Song does not support queueing for Apple Music 95 | playlists. 96 | 97 | ## Searching songs on Google 98 | 99 | For any of the above filters, choosing a result with the `ctrl` key held down 100 | will search the result on Google. 101 | 102 | ### Clearing the cache 103 | 104 | Play Song stores a local cache containing album artwork (from displayed 105 | results), as well as the compiled workflow configuration. If you experience any 106 | issues with Play Song, you can clear this cache via the `clearcache` keyword. 107 | 108 | ### A note about play order 109 | 110 | Play Song always respects the current shuffle mode within Music.app. For example, 111 | if shuffle is enabled, playing an album via Play Song will play the songs of the 112 | album in shuffled order. Therefore, if you desire Play Song to respect album 113 | order, run the `shuffleoff` command within Alfred. 114 | 115 | Additionally, Play Song will honor the column you are sorting by in Music.app to 116 | sort queued songs. This applies to keyword filters like `playartist`, 117 | `playalbum`, and `playgenre`. For example, if you are sorting by "Album by 118 | Artist" in your library, then Play Song will group artists together and group 119 | albums together when using `playgenre`. However, if you are sorting by "Title" 120 | in your library, then Play Song will disregard album groupings and simply play 121 | songs in alphabetical order. 122 | 123 | The `playplaylist` filter is the only Play Song filter where play order is 124 | guaranteed. 125 | 126 | ### Playing a song directly (the Play Song v1 behavior) 127 | 128 | If you are a longtime Play Song user who prefers the v1 behavior for playing 129 | songs (where music continues playing after the song finishes), you can do so via 130 | the `shift` modifier. Note that this only works for the `playsong` filter. 131 | 132 | ## Support 133 | 134 | If you have a bug to report or a feature to request, please [submit an issue on 135 | GitHub](https://github.com/caleb531/play-song/issues). You can also [contact me 136 | directly](https://calebevans.me/contact/) via email. 137 | -------------------------------------------------------------------------------- /actions/clearcache.applescript: -------------------------------------------------------------------------------- 1 | -- clears workflow cache, including album artwork and compiled scripts -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on run query 8 | set config to loadConfig() 9 | try 10 | tell application "Finder" 11 | delete folder (workflowCacheFolder of config) 12 | end tell 13 | tell application "Music" 14 | delete user playlist (workflowPlaylistName of config) 15 | end tell 16 | end try 17 | end run 18 | -------------------------------------------------------------------------------- /actions/clearqueue.applescript: -------------------------------------------------------------------------------- 1 | -- clears workflow queue in Music.app -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on run query 8 | set config to loadConfig() 9 | clearQueue() of config 10 | end run 11 | -------------------------------------------------------------------------------- /actions/getsearchquery.applescript: -------------------------------------------------------------------------------- 1 | -- get search query for Google search -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on run query 8 | set config to loadConfig() 9 | set typeAndId to parseResultQuery(query as text) of config 10 | set theType to type of typeAndId 11 | set theId to id of typeAndId 12 | if theType is "song" then 13 | tell application "Music" 14 | set theSong to getSong(theId) of config 15 | set songName to name of theSong 16 | set songArtist to artist of theSong 17 | return songName & " by " & songArtist 18 | end tell 19 | else 20 | return theId 21 | end if 22 | end run 23 | -------------------------------------------------------------------------------- /actions/play-direct.applescript: -------------------------------------------------------------------------------- 1 | -- plays selected song or playlist directly (the Play Song v1 behavior) 2 | -- for songs, this behavior continues playing music after the song finishes 3 | 4 | on loadConfig() 5 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 6 | end loadConfig 7 | 8 | on run query 9 | set config to loadConfig() 10 | 11 | set typeAndId to parseResultQuery(query as text) of config 12 | set theType to type of typeAndId 13 | set theId to id of typeAndId 14 | 15 | tell application "Music" 16 | 17 | if theType is "song" then 18 | 19 | set theSong to getSong(theId) of config 20 | play theSong 21 | 22 | else if theType ends with "playlist" then 23 | 24 | set thePlaylist to getPlaylist(theId) of config 25 | play thePlaylist 26 | 27 | else 28 | 29 | log "Unsupported type: " & theType 30 | 31 | end if 32 | 33 | end tell 34 | end run 35 | -------------------------------------------------------------------------------- /actions/play.applescript: -------------------------------------------------------------------------------- 1 | -- plays selected result in Music.app -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on run query 8 | set config to loadConfig() 9 | play(query as text) of config 10 | end run 11 | -------------------------------------------------------------------------------- /actions/playqueue.applescript: -------------------------------------------------------------------------------- 1 | -- plays workflow queue in Music.app -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on run query 8 | set config to loadConfig() 9 | playQueue() of config 10 | end run 11 | -------------------------------------------------------------------------------- /actions/queue.applescript: -------------------------------------------------------------------------------- 1 | -- queues selected result in Music.app -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on run query 8 | set config to loadConfig() 9 | queue(query as text) of config 10 | end run 11 | -------------------------------------------------------------------------------- /actions/shuffleoff.applescript: -------------------------------------------------------------------------------- 1 | -- disables the global Shuffle Mode in the Music app -- 2 | 3 | on run query 4 | tell application "Music" 5 | set shuffle enabled to false 6 | return (get shuffle enabled as string) 7 | end tell 8 | end run 9 | -------------------------------------------------------------------------------- /actions/shuffleon.applescript: -------------------------------------------------------------------------------- 1 | -- enables the global Shuffle Mode in the Music app -- 2 | 3 | on run query 4 | tell application "Music" 5 | set shuffle enabled to true 6 | return (get shuffle enabled as string) 7 | end tell 8 | end run 9 | -------------------------------------------------------------------------------- /actions/shuffletoggle.applescript: -------------------------------------------------------------------------------- 1 | -- toggles the global Shuffle Mode in the Music app -- 2 | 3 | on run query 4 | tell application "Music" 5 | set shuffle enabled to not (get shuffle enabled) 6 | return (get shuffle enabled as string) 7 | end tell 8 | end run 9 | -------------------------------------------------------------------------------- /filters/playalbum.applescript: -------------------------------------------------------------------------------- 1 | -- playalbum filter -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on getAlbumResultListFeedback(query) 8 | 9 | global config 10 | 11 | set query to trimWhitespace(query) of config 12 | 13 | tell application "Music" 14 | 15 | set theAlbums to getResultsFromQuery(query, "album") of config 16 | 17 | repeat with albumName in theAlbums 18 | 19 | set albumName to albumName as text 20 | set theSong to (first track of playlist 2 whose album is albumName) 21 | set songArtworkPath to getSongArtworkPath(theSong) of config 22 | 23 | addResult({uid:("album-" & albumName), valid:"yes", title:albumName, subtitle:artist of theSong, icon:songArtworkPath}) of config 24 | 25 | end repeat 26 | 27 | if config's resultListIsEmpty() then 28 | 29 | addNoResultsItem(query, "album") of config 30 | 31 | end if 32 | 33 | end tell 34 | 35 | return getResultListFeedback(query) of config 36 | 37 | end getAlbumResultListFeedback 38 | 39 | on run query 40 | set config to loadConfig() 41 | getAlbumResultListFeedback(query as text) 42 | end run 43 | -------------------------------------------------------------------------------- /filters/playalbumby.applescript: -------------------------------------------------------------------------------- 1 | -- playalbumby filter -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on getAlbumResultListFeedback(query) 8 | 9 | global config 10 | 11 | set query to trimWhitespace(query) of config 12 | 13 | tell application "Music" 14 | 15 | set theArtists to getResultsFromQuery(query, "artist") of config 16 | 17 | repeat with artistName in theArtists 18 | 19 | set artistAlbums to getArtistAlbums(artistName) of config 20 | 21 | repeat with albumName in artistAlbums 22 | 23 | if config's resultListIsFull() then exit repeat 24 | 25 | set albumName to albumName as text 26 | set theSong to (first track of playlist 2 whose album is albumName) 27 | set songArtworkPath to getSongArtworkPath(theSong) of config 28 | 29 | addResult({uid:("album-" & albumName), valid:"yes", title:albumName, subtitle:artist of theSong, icon:songArtworkPath}) of config 30 | 31 | end repeat 32 | 33 | if config's resultListIsFull() then exit repeat 34 | 35 | end repeat 36 | 37 | if config's resultListIsEmpty() then 38 | 39 | addNoResultsItem(query, "album") of config 40 | 41 | end if 42 | 43 | end tell 44 | 45 | return getResultListFeedback(query) of config 46 | 47 | end getAlbumResultListFeedback 48 | 49 | on run query 50 | set config to loadConfig() 51 | getAlbumResultListFeedback(query as text) 52 | end run 53 | -------------------------------------------------------------------------------- /filters/playartist.applescript: -------------------------------------------------------------------------------- 1 | -- playartist filter -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on getArtistResultListFeedback(query) 8 | 9 | global config 10 | 11 | set query to trimWhitespace(query) of config 12 | 13 | tell application "Music" 14 | 15 | set theArtists to getResultsFromQuery(query, "artist") of config 16 | 17 | repeat with artistName in theArtists 18 | 19 | set artistName to artistName as text 20 | set theSong to (first track of playlist 2 whose artist is artistName) 21 | set songArtworkPath to getSongArtworkPath(theSong) of config 22 | 23 | addResult({uid:("artist-" & artistName), valid:"yes", title:artistName, subtitle:genre of theSong, icon:songArtworkPath}) of config 24 | 25 | end repeat 26 | 27 | if config's resultListIsEmpty() then 28 | 29 | addNoResultsItem(query, "artist") of config 30 | 31 | end if 32 | 33 | end tell 34 | 35 | return getResultListFeedback(query) of config 36 | 37 | end getArtistResultListFeedback 38 | 39 | on run query 40 | set config to loadConfig() 41 | getArtistResultListFeedback(query as text) 42 | end run 43 | -------------------------------------------------------------------------------- /filters/playgenre.applescript: -------------------------------------------------------------------------------- 1 | -- playgenre filter -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on getGenreResultListFeedback(query) 8 | 9 | global config 10 | 11 | set query to trimWhitespace(query) of config 12 | 13 | tell application "Music" 14 | 15 | set theGenres to getResultsFromQuery(query, "genre") of config 16 | 17 | repeat with genreName in theGenres 18 | 19 | set genreName to genreName as text 20 | set theSong to (first track of playlist 2 whose genre is genreName) 21 | set songArtworkPath to getSongArtworkPath(theSong) of config 22 | 23 | addResult({uid:("genre-" & genreName), valid:"yes", title:genreName, subtitle:"Genre", icon:songArtworkPath}) of config 24 | 25 | end repeat 26 | 27 | if config's resultListIsEmpty() then 28 | 29 | addNoResultsItem(query, "genre") of config 30 | 31 | end if 32 | 33 | end tell 34 | 35 | return getResultListFeedback(query) of config 36 | 37 | end getGenreResultListFeedback 38 | 39 | on run query 40 | set config to loadConfig() 41 | getGenreResultListFeedback(query as text) 42 | end run 43 | -------------------------------------------------------------------------------- /filters/playplaylist.applescript: -------------------------------------------------------------------------------- 1 | -- playplaylist filter -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on getPlaylistResultListFeedback(query) 8 | 9 | global config 10 | 11 | set query to trimWhitespace(query) of config 12 | 13 | tell application "Music" 14 | 15 | -- retrieve list of playlists matching query (ordered by relevance) 16 | set thePlaylists to (get playlists whose name starts with query and name is not config's workflowPlaylistName and special kind is none and size is not 0) 17 | 18 | if length of thePlaylists < config's resultLimit then 19 | 20 | set thePlaylists to thePlaylists & (get playlists whose name contains (space & query) and name does not start with query and name is not config's workflowPlaylistName and special kind is none and size is not 0) 21 | 22 | end if 23 | 24 | if length of thePlaylists < config's resultLimit then 25 | 26 | set thePlaylists to thePlaylists & (get playlists whose name contains query and name does not start with query and name does not contain (space & query) and name is not config's workflowPlaylistName and special kind is none and size is not 0) 27 | 28 | end if 29 | 30 | if length of thePlaylists > config's resultLimit then 31 | 32 | set thePlaylists to items 1 thru (config's resultLimit) of thePlaylists 33 | 34 | end if 35 | 36 | repeat with thePlaylist in thePlaylists 37 | 38 | set playlistName to name of thePlaylist 39 | set playlistId to id of thePlaylist 40 | set songCount to number of tracks in thePlaylist 41 | set playlistDuration to time of thePlaylist 42 | 43 | try 44 | 45 | set theSong to first track in thePlaylist 46 | set songArtworkPath to getSongArtworkPath(theSong) of config 47 | 48 | set itemSubtitle to (quantifyNumber(songCount, "song", "songs") of config) & ", " & playlistDuration & " in length" 49 | 50 | -- Play Song does not support queueing Apple Music playlists 51 | -- because they can contain songs which the user has not added 52 | -- to their library (and such non-library songs cannot be 53 | -- programmatically added to a non-Apple Music playlist); 54 | -- therefore, we need to separate Apple Music playlists from 55 | -- other types of playlists 56 | if class of thePlaylist is subscription playlist then 57 | set prefixedPlaylistId to "subscription_playlist-" & playlistId 58 | else 59 | set prefixedPlaylistId to "playlist-" & playlistId 60 | end if 61 | addResult({uid:prefixedPlaylistId as text, valid:"yes", title:playlistName, subtitle:itemSubtitle, icon:songArtworkPath}) of config 62 | 63 | on error number -1728 64 | end try 65 | 66 | end repeat 67 | 68 | if config's resultListIsEmpty() then 69 | 70 | addNoResultsItem(query, "playlist") of config 71 | 72 | end if 73 | 74 | end tell 75 | 76 | return getResultListFeedback(query) of config 77 | 78 | end getPlaylistResultListFeedback 79 | 80 | on run query 81 | set config to loadConfig() 82 | getPlaylistResultListFeedback(query as text) 83 | end run 84 | -------------------------------------------------------------------------------- /filters/playsong.applescript: -------------------------------------------------------------------------------- 1 | -- playsong filter -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on getSongResultListFeedback(query) 8 | 9 | global config 10 | 11 | set query to trimWhitespace(query) of config 12 | 13 | tell application "Music" 14 | 15 | set theSongs to getResultsFromQuery(query, "name") of config 16 | -- 17 | repeat with theSong in theSongs 18 | 19 | set songId to (get database ID of theSong) 20 | set songName to name of theSong 21 | set songArtist to artist of theSong 22 | set songArtworkPath to getSongArtworkPath(theSong) of config 23 | 24 | addResult({uid:("song-" & songId), valid:"yes", title:songName, subtitle:songArtist, icon:songArtworkPath}) of config 25 | 26 | end repeat 27 | 28 | if config's resultListIsEmpty() then 29 | 30 | addNoResultsItem(query, "song") of config 31 | 32 | end if 33 | 34 | end tell 35 | 36 | return getResultListFeedback(query) of config 37 | 38 | end getSongResultListFeedback 39 | 40 | on run query 41 | set config to loadConfig() 42 | getSongResultListFeedback(query as text) 43 | end run 44 | -------------------------------------------------------------------------------- /filters/playsongby.applescript: -------------------------------------------------------------------------------- 1 | -- playsongby filter -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on getArtistResultListFeedback(query) 8 | 9 | global config 10 | 11 | set query to trimWhitespace(query) of config 12 | 13 | tell application "Music" 14 | 15 | set theArtists to getResultsFromQuery(query, "artist") of config 16 | 17 | repeat with artistName in theArtists 18 | 19 | set artistSongs to getArtistSongs(artistName) of config 20 | 21 | repeat with theSong in artistSongs 22 | 23 | if config's resultListIsFull() then exit repeat 24 | 25 | set songId to (get database ID of theSong) 26 | set songName to name of theSong 27 | set songArtworkPath to getSongArtworkPath(theSong) of config 28 | 29 | addResult({uid:("song-" & songId), valid:"yes", title:songName, subtitle:artist of theSong, icon:songArtworkPath}) of config 30 | 31 | end repeat 32 | 33 | if config's resultListIsFull() then exit repeat 34 | 35 | end repeat 36 | 37 | if config's resultListIsEmpty() then 38 | 39 | addNoResultsItem(query, "song") of config 40 | 41 | end if 42 | 43 | end tell 44 | 45 | return getResultListFeedback(query) of config 46 | 47 | end getArtistResultListFeedback 48 | 49 | on run query 50 | set config to loadConfig() 51 | getArtistResultListFeedback(query as text) 52 | end run 53 | -------------------------------------------------------------------------------- /filters/playsongin.applescript: -------------------------------------------------------------------------------- 1 | -- playsongin filter -- 2 | 3 | on loadConfig() 4 | return (load script POSIX file (do shell script "bash ./resources/compile-config.sh")) 5 | end loadConfig 6 | 7 | on getAlbumResultListFeedback(query) 8 | 9 | global config 10 | 11 | set query to trimWhitespace(query) of config 12 | 13 | tell application "Music" 14 | 15 | set theAlbums to getResultsFromQuery(query, "album") of config 16 | 17 | repeat with albumName in theAlbums 18 | 19 | set albumSongs to getAlbumSongs(albumName) of config 20 | 21 | repeat with theSong in albumSongs 22 | 23 | if config's resultListIsFull() then exit repeat 24 | 25 | set songId to (get database ID of theSong) 26 | set songName to name of theSong 27 | set songArtworkPath to getSongArtworkPath(theSong) of config 28 | 29 | addResult({uid:("song-" & songId), valid:"yes", title:songName, subtitle:artist of theSong, icon:songArtworkPath}) of config 30 | 31 | end repeat 32 | 33 | if config's resultListIsFull() then exit repeat 34 | 35 | end repeat 36 | 37 | if config's resultListIsEmpty() then 38 | 39 | addNoResultsItem(query, "song") of config 40 | 41 | end if 42 | 43 | end tell 44 | 45 | return getResultListFeedback(query) of config 46 | 47 | end getAlbumResultListFeedback 48 | 49 | on run query 50 | set config to loadConfig() 51 | getAlbumResultListFeedback(query as text) 52 | end run 53 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/play-song/90e3bb89eda91ccd30aab866b9b30ad38a624a5c/icon.png -------------------------------------------------------------------------------- /packager.json: -------------------------------------------------------------------------------- 1 | { 2 | "export_files": [ 3 | "Play Song (Alfred 5).alfredworkflow" 4 | ], 5 | "bundle_id": "com.calebevans.playsong", 6 | "resources": [ 7 | "icon.png", 8 | "resources/*", 9 | "filters/*", 10 | "actions/*" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alfred-workflow-packager==3.2.0 2 | attrs==25.3.0 3 | jsonschema==4.23.0 4 | jsonschema-specifications==2024.10.1 5 | referencing==0.36.2 6 | rpds-py==0.24.0 7 | -------------------------------------------------------------------------------- /resources/compile-config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Compiles configuration as AppleScript 3 | 4 | installed_config_dir="$(dirname "$0")" 5 | 6 | # Create necessary workflow cache directories if they don't exist 7 | cache_dir="$HOME/Library/Caches/com.runningwithcrayons.Alfred/Workflow Data/com.calebevans.playsong" 8 | mkdir -p "$cache_dir" 9 | mkdir -p "$cache_dir/Album Artwork" 10 | 11 | # Get paths to configuration file 12 | installed_config="$installed_config_dir/config.applescript" 13 | compiled_config="$cache_dir/config.scpt" 14 | cached_config_md5file="$cache_dir/config.applescript.md5" 15 | 16 | # Retrieve md5sum of config files 17 | installed_config_md5sum="$(md5 -q "$installed_config")" 18 | cached_config_md5sum="$(cat "$cached_config_md5file")" 19 | 20 | # If cached md5sum does not match installed config's md5sum 21 | if [ ! -f "$compiled_config" ] || [ "$installed_config_md5sum" != "$cached_config_md5sum" ]; then 22 | 23 | # Cache installed config's md5sum 24 | echo "$installed_config_md5sum" > "$cached_config_md5file" 25 | osacompile -o "$compiled_config" "$installed_config" 26 | 27 | fi 28 | 29 | # Output path to compiled config file for use by workflow scripts 30 | echo "$compiled_config" 31 | -------------------------------------------------------------------------------- /resources/config.applescript: -------------------------------------------------------------------------------- 1 | -- core configuration -- 2 | 3 | -- configurable options -- 4 | 5 | -- limit number of songs to improve efficiency 6 | property resultLimit : 15 7 | -- whether or not to retrieve/display album artwork for each result 8 | property albumArtEnabled : true 9 | 10 | -- workflow parameters -- 11 | 12 | -- workflow folders 13 | property libraryFolder : (path to library folder from user domain as text) 14 | property cacheFolder : (libraryFolder & "Caches:") 15 | property alfredWorkflowDataFolder : (cacheFolder & "com.runningwithcrayons.Alfred:Workflow Data:") 16 | property bundleId : "com.calebevans.playsong" 17 | property workflowCacheFolder : (alfredWorkflowDataFolder & bundleId & ":") as text 18 | 19 | -- the default icon used for search results without album artwork 20 | property defaultIconName : "resources/icon-noartwork.png" 21 | -- the name of the playlist used by the workflow for playing songs 22 | property workflowPlaylistName : "Alfred Play Song" 23 | -- list of Alfred results 24 | property resultList : {} 25 | 26 | -- replaces substring in string with another substring 27 | on replace(replaceThis, replaceWith, theString) 28 | 29 | set oldDelims to AppleScript's text item delimiters 30 | set AppleScript's text item delimiters to replaceThis 31 | set strItems to text items of theString 32 | set AppleScript's text item delimiters to replaceWith 33 | set newString to strItems as text 34 | set AppleScript's text item delimiters to oldDelims 35 | return newString 36 | 37 | end replace 38 | 39 | -- retrieve the plural/singular form of a quantity based on the given number 40 | on quantifyNumber(theNumber, quantityName, pluralQuantityName) 41 | 42 | if theNumber is 1 then 43 | 44 | set theString to (theNumber as text) & space & quantityName 45 | 46 | else 47 | 48 | set theString to (theNumber as text) & space & pluralQuantityName 49 | 50 | end if 51 | 52 | return theString 53 | 54 | end quantifyNumber 55 | 56 | -- encodes JSON reserved characters in the given string 57 | on encodeFeedbackChars(theString) 58 | 59 | set theString to replace("\\", "\\\\", theString) 60 | set theString to replace("\"", "\\\"", theString) 61 | return theString 62 | 63 | end encodeFeedbackChars 64 | 65 | -- decodes JSON reserved characters in the given string 66 | on decodeFeedbackChars(theString) 67 | 68 | set theString to replace("\\\\", "\\", theString) 69 | set theString to replace("\\\"", "\"", theString) 70 | return theString 71 | 72 | end decodeFeedbackChars 73 | 74 | -- adds Alfred result to result list 75 | on addResult(theResult) 76 | 77 | copy theResult to the end of resultList 78 | 79 | end addResult 80 | 81 | -- adds item for "No Results" message 82 | on addNoResultsItem(query, queryType) 83 | 84 | addResult({uid:"no-results", valid:"no", title:"No Results Found", subtitle:("No " & queryType & "s matching '" & query & "'"), icon:defaultIconName}) 85 | 86 | end addNoResultsItem 87 | 88 | on resultListIsFull() 89 | 90 | return (length of resultList is resultLimit) 91 | 92 | end resultListIsFull 93 | 94 | on resultListIsEmpty() 95 | 96 | return (length of resultList is 0) 97 | 98 | end resultListIsFull 99 | 100 | -- builds Alfred result item as JSON 101 | on getResultFeedback(theResult, query) 102 | 103 | -- encode reserved JSON characters 104 | set resultUid to encodeFeedbackChars(uid of theResult) 105 | set resultValid to (valid of theResult) as text 106 | set resultTitle to encodeFeedbackChars(title of theResult) 107 | set resultSubtitle to encodeFeedbackChars(subtitle of theResult) 108 | set typeAndId to parseResultQuery(resultUid) 109 | set resultType to type of typeAndId 110 | set resultId to id of typeAndId 111 | 112 | if (icon of theResult) contains ":" then 113 | 114 | set resultIcon to encodeFeedbackChars(POSIX path of icon of theResult) 115 | 116 | else 117 | 118 | set resultIcon to icon of theResult 119 | 120 | end if 121 | 122 | set json to "{" 123 | set json to json & "\"uid\":\"" & resultUid & "\"," 124 | set json to json & "\"arg\":\"" & resultUid & "\"," 125 | set json to json & "\"valid\":\"" & resultValid & "\"," 126 | set json to json & "\"title\":\"" & resultTitle & "\"," 127 | set json to json & "\"subtitle\":\"" & resultSubtitle & "\"," 128 | set json to json & "\"text\":{" 129 | set json to json & "\"copy\":\"" & resultTitle & "\"," 130 | set json to json & "\"largetype\":\"" & resultTitle & "\"" 131 | set json to json & "}," 132 | set json to json & "\"variables\":{" 133 | if resultType is "playlist" or resultType is "subscription_playlist" then 134 | set json to json & "\"action\":\"play_directly\"" 135 | else 136 | set json to json & "\"action\":\"play\"" 137 | end if 138 | set json to json & "}," 139 | set json to json & "\"mods\":{" 140 | set json to json & "\"cmd\":{" 141 | if resultType is "subscription_playlist" then 142 | set json to json & "\"subtitle\":\"Queueing Apple Music playlists is not supported at this time\"," 143 | set json to json & "\"valid\":\"no\"," 144 | else 145 | set json to json & "\"subtitle\":\"Queue " & resultType & "\"," 146 | end if 147 | set json to json & "\"variables\":{" 148 | set json to json & "\"action\":\"queue\"" 149 | set json to json & "}" 150 | set json to json & "}," 151 | if resultType is "song" then 152 | set json to json & "\"shift\":{" 153 | set json to json & "\"subtitle\":\"Play" & space & resultType & space & "directly (the v1 behavior)\"," 154 | set json to json & "\"variables\":{" 155 | set json to json & "\"action\":\"play_directly\"" 156 | set json to json & "}" 157 | set json to json & "}," 158 | end if 159 | set json to json & "\"ctrl\":{" 160 | set json to json & "\"subtitle\":\"Search on web\"," 161 | set json to json & "\"variables\":{" 162 | set json to json & "\"action\":\"search_on_web\"," 163 | if resultType is "artist" or resultType is "genre" then 164 | set json to json & "\"search_query\":\"" & resultTitle & "\"" 165 | else if resultUid is "no-results" then 166 | set json to json & "\"search_query\":\"" & query & "\"" 167 | else 168 | set json to json & "\"search_query\":\"" & resultTitle & space & "-" & space & resultSubtitle & "\"" 169 | end if 170 | set json to json & "}" 171 | set json to json & "}" 172 | set json to json & "}," 173 | set json to json & "\"icon\":{\"path\":\"" & resultIcon & "\"}" 174 | set json to json & "}" 175 | return json 176 | 177 | end getResultFeedback 178 | 179 | -- retrieves JSON document for Alfred results 180 | on getResultListFeedback(query) 181 | 182 | set json to "{\"items\": [" 183 | 184 | repeat with theResult in resultList 185 | 186 | set json to json & getResultFeedback(theResult, query) 187 | set json to json & "," 188 | 189 | end repeat 190 | 191 | -- remove trailing comma after last item 192 | if last character of json is "," then set json to text 1 thru (length of json - 1) of json 193 | set json to json & "]}" 194 | return json 195 | 196 | end getResultListFeedback 197 | 198 | -- query path to artwork image file cached natively by Music.app in Catalina 199 | on getSongArtworkPath(theSong) 200 | 201 | try 202 | 203 | if albumArtEnabled is false then return defaultIconName 204 | 205 | tell application "Music" 206 | 207 | -- get persistent ID of song and use it to fetch album artwork 208 | set songId to persistent ID of theSong 209 | -- the base path to the artwork file (without extension) 210 | set artworkPath to (do shell script "sh ./resources/get-song-artwork-path.sh" & space & songId & space & defaultIconName) 211 | 212 | end tell 213 | 214 | return artworkPath 215 | 216 | on error errorMessage 217 | 218 | log errorMessage 219 | 220 | end try 221 | 222 | end getSongArtworkPath 223 | 224 | -- creates album artwork cache 225 | on createWorkflowPlaylist() 226 | 227 | tell application "Music" 228 | 229 | if not (user playlist workflowPlaylistName exists) then 230 | 231 | make new user playlist with properties {name:workflowPlaylistName, shuffle:false} 232 | 233 | end if 234 | 235 | end tell 236 | 237 | end createWorkflowPlaylist 238 | 239 | on clearQueue() 240 | 241 | tell application "Music" 242 | 243 | if user playlist workflowPlaylistName exists then 244 | 245 | delete tracks of user playlist workflowPlaylistName 246 | 247 | end if 248 | 249 | end tell 250 | 251 | end clearQueue 252 | 253 | on queueSongs(theSongs) 254 | 255 | tell application "Music" 256 | 257 | repeat with theSong in theSongs 258 | 259 | duplicate theSong to user playlist workflowPlaylistName 260 | 261 | end repeat 262 | 263 | end tell 264 | 265 | end queueSongs 266 | 267 | on playQueue() 268 | 269 | tell application "Music" 270 | 271 | if number of tracks in user playlist workflowPlaylistName is not 0 then 272 | 273 | play user playlist workflowPlaylistName 274 | 275 | end if 276 | 277 | end tell 278 | 279 | end playQueue 280 | 281 | on getPlaylist(playlistId) 282 | 283 | tell application "Music" 284 | 285 | return (first playlist whose id is playlistId) 286 | 287 | end tell 288 | 289 | end getPlaylist 290 | 291 | on getPlaylistSongs(playlistId) 292 | 293 | tell application "Music" 294 | 295 | set thePlaylist to getPlaylist(playlistId) of me 296 | set playlistSongs to every track of thePlaylist 297 | 298 | end tell 299 | 300 | return playlistSongs 301 | 302 | end getPlaylistSongs 303 | 304 | -- retrieves list of songs within the given genre, sorted by artist 305 | on getGenreSongs(genreName) 306 | 307 | tell application "Music" 308 | 309 | set genreSongs to every track of playlist 2 whose genre is genreName 310 | 311 | end tell 312 | 313 | return genreSongs 314 | 315 | end getGenreSongs 316 | 317 | -- retrieves list of album names for the given artist 318 | on getArtistAlbums(artistName) 319 | 320 | tell application "Music" 321 | 322 | set artistSongs to every track of playlist 2 whose artist is artistName 323 | set albumNames to {} 324 | 325 | repeat with theSong in artistSongs 326 | 327 | if (album of theSong) is not in albumNames then 328 | 329 | set albumNames to albumNames & (album of theSong) 330 | 331 | end if 332 | 333 | end repeat 334 | 335 | end tell 336 | 337 | return albumNames 338 | 339 | end getArtistAlbums 340 | 341 | -- retrieves list of songs by the given artist, sorted by album 342 | on getArtistSongs(artistName) 343 | 344 | tell application "Music" 345 | 346 | set artistSongs to every track of playlist 2 whose artist is artistName 347 | 348 | end tell 349 | 350 | return artistSongs 351 | 352 | end getArtistSongs 353 | 354 | -- retrieves list of songs in the given album 355 | on getAlbumSongs(albumName) 356 | 357 | tell application "Music" 358 | 359 | set albumSongs to every track of playlist 2 whose album is albumName 360 | 361 | end tell 362 | 363 | return albumSongs 364 | 365 | end getAlbumSongs 366 | 367 | -- retrieves the song with the given ID 368 | on getSong(songId) 369 | 370 | tell application "Music" 371 | 372 | set theSong to first track of playlist 2 whose database ID is songId 373 | 374 | end tell 375 | 376 | return theSong 377 | 378 | end getSong 379 | 380 | -- retrieves a list of objects or names matching the given query and type 381 | on getResultsFromQuery(query, queryType) 382 | 383 | set evalScript to run script " 384 | script 385 | 386 | on findResults(query, queryType, resultLimit) 387 | 388 | tell application \"Music\" 389 | 390 | set theSongs to (get every track in playlist 2 whose " & queryType & " starts with query) 391 | 392 | if length of theSongs < resultLimit then 393 | 394 | set theSongs to theSongs & (get every track in playlist 2 whose " & queryType & " contains (space & query) and " & queryType & " does not start with query) 395 | 396 | end if 397 | 398 | if length of theSongs < resultLimit then 399 | 400 | set theSongs to theSongs & (get every track in playlist 2 whose " & queryType & " contains query and " & queryType & " does not start with query and " & queryType & " does not contain (space & query)) 401 | 402 | end if 403 | 404 | if length of theSongs is 0 then 405 | 406 | if queryType is \"name\" then 407 | 408 | set theSongs to theSongs & (search playlist 2 for query only songs) 409 | 410 | else if queryType is not \"genre\" then 411 | 412 | set theSongs to theSongs & (search playlist 2 for query only " & queryType & "s) 413 | 414 | end if 415 | 416 | end if 417 | 418 | if queryType is \"name\" then 419 | 420 | if length of theSongs > resultLimit then 421 | 422 | set theSongs to items 1 thru resultLimit of theSongs 423 | 424 | end if 425 | 426 | set theResults to theSongs 427 | 428 | else 429 | 430 | set theResults to {} 431 | 432 | repeat with theSong in theSongs 433 | 434 | if length of theResults is resultLimit then exit repeat 435 | 436 | set theResult to " & queryType & " of theSong 437 | 438 | if theResult is not in theResults then 439 | 440 | set theResults to theResults & theResult 441 | 442 | end if 443 | 444 | end repeat 445 | 446 | end if 447 | 448 | end tell 449 | 450 | return theResults 451 | 452 | end findResults 453 | 454 | end script 455 | " 456 | 457 | evalScript's findResults(query, queryType, resultLimit) 458 | 459 | end getResultsFromQuery 460 | 461 | -- returns the given string with leading and trailing whitespace removed 462 | on trimWhitespace(theString) 463 | 464 | -- trim leading whitespace 465 | repeat while theString begins with space 466 | 467 | if length of theString is 1 then return "" 468 | set theString to text 2 thru end of theString 469 | 470 | end repeat 471 | 472 | -- trim trailing whitespace 473 | repeat while theString ends with space 474 | 475 | set theString to text 1 thru ((length of theString) - 1) of theString 476 | 477 | end repeat 478 | 479 | return theString 480 | 481 | end trimWhitespace 482 | 483 | -- queues the song with the given ID 484 | on queueSong(songId) 485 | 486 | set theSong to getSong(songId) 487 | queueSongs({theSong}) 488 | 489 | end queueSong 490 | 491 | -- queues all songs belonging to the given album 492 | on queueAlbum(albumName) 493 | 494 | set albumName to decodeFeedbackChars(albumName) 495 | set albumSongs to getAlbumSongs(albumName) 496 | queueSongs(albumSongs) 497 | 498 | end queueAlbum 499 | 500 | -- queues all songs by the given artist 501 | on queueArtist(artistName) 502 | 503 | set artistName to decodeFeedbackChars(artistName) 504 | set artistSongs to getArtistSongs(artistName) 505 | queueSongs(artistSongs) 506 | 507 | end queueArtist 508 | 509 | -- queues all songs within the given genre 510 | on queueGenre(genreName) 511 | 512 | set genreName to decodeFeedbackChars(genreName) 513 | set genreSongs to getGenreSongs(genreName) 514 | queueSongs(genreSongs) 515 | 516 | end queueGenre 517 | 518 | -- queues all songs in the given playlist 519 | on queuePlaylist(playlistId) 520 | 521 | set playlistSongs to getPlaylistSongs(playlistId) 522 | queueSongs(playlistSongs) 523 | 524 | end queuePlaylist 525 | 526 | -- parses the given result query to retrieve type and id of item to queue 527 | on parseResultQuery(query) 528 | 529 | set pos to offset of "-" in query 530 | set theType to text 1 thru (pos - 1) of query 531 | set theId to text (pos + 1) thru end of query 532 | return {type:theType, id:theId} 533 | 534 | end parseResultQuery 535 | 536 | on queue(query) 537 | 538 | set typeAndId to parseResultQuery(query) 539 | set theType to type of typeAndId 540 | set theId to id of typeAndId 541 | 542 | createWorkflowPlaylist() 543 | 544 | if theType is "song" then 545 | queueSong(theId) 546 | else if theType is "album" then 547 | queueAlbum(theId) 548 | else if theType is "artist" then 549 | queueArtist(theId) 550 | else if theType is "genre" then 551 | queueGenre(theId) 552 | else if theType is "playlist" then 553 | queuePlaylist(theId) 554 | else 555 | log "Unsupported type: " & theType 556 | end if 557 | 558 | end queue 559 | 560 | on play(query) 561 | 562 | clearQueue() 563 | queue(query) 564 | playQueue() 565 | 566 | end play 567 | -------------------------------------------------------------------------------- /resources/get-song-artwork-path.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Path to the directory containing the album artwork database 4 | ARTWORK_DB_DIR=~/Library/Containers/com.apple.AMPArtworkAgent/Data/Documents 5 | # Path to the directory of album artwork images 6 | ARTWORK_IMG_DIR="$ARTWORK_DB_DIR"/artwork 7 | 8 | # The persistent ID of the song; the ID is initially supplied to this script as 9 | # a hexadecimal value (base-16), however it must be converted to decimal 10 | # (base-10) to be used in the database query that follows 11 | song_persistent_id=$((0x$1)) 12 | # The path of the fallback icon to use if no image artwork is available 13 | default_icon_path="$2" 14 | 15 | # Retrieve the name of the album artwork image (without extension) 16 | artwork_name=$(/usr/bin/sqlite3 \ 17 | -list \ 18 | -noheader \ 19 | "$ARTWORK_DB_DIR"/artworkd.sqlite " 20 | select ZHASHSTRING, ZKIND from ZIMAGEINFO where Z_PK = ( 21 | select ZIMAGEINFO from ZSOURCEINFO where Z_PK = ( 22 | select ZSOURCEINFO from ZDATABASEITEMINFO where ZPERSISTENTID = $song_persistent_id 23 | ) 24 | )" | awk '{split($0,a,"|"); print a[1] "_sk_" a[2] "_cid_1"}') 25 | 26 | # Return the path and file extension which produces a file that exists 27 | artwork_base_path="$ARTWORK_IMG_DIR"/"$artwork_name" 28 | if [ -f "$artwork_base_path".jpeg ]; then 29 | echo "$artwork_base_path".jpeg 30 | elif [ -f "$artwork_base_path".png ]; then 31 | echo "$artwork_base_path".png 32 | else 33 | echo "$default_icon_path" 34 | fi 35 | -------------------------------------------------------------------------------- /resources/icon-noartwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/play-song/90e3bb89eda91ccd30aab866b9b30ad38a624a5c/resources/icon-noartwork.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/play-song/90e3bb89eda91ccd30aab866b9b30ad38a624a5c/screenshot.png --------------------------------------------------------------------------------