Skip to content
This repository

Port iTunes appscript to applescript. #178 #181

Closed
wants to merge 3 commits into from

3 participants

Joey Wendt Jon Maddox Zach Holman
Joey Wendt
joeyw commented

Replace all the iTunes appscript with applescript. Makes play with iTunes 10.6.3 and hopefully future versions. Backwards compatible with current play and doesn't change any of the functionality.

If we want to keep iTunes for now this route will work and we can refactor a bunch of this and make it not so horrible but for now it just works.

Jon Maddox
Owner
maddox commented

:metal::metal::metal:

Zach Holman holman referenced this pull request
Closed

iTunes 10.6.3 breaks play #178

Joey Wendt
joeyw commented

Added some caching to bypass blocking osascript calls to find song data. Cache loads lazily each time a song isn't found in the cache and can be manually warmed up by running rake warm_cache.

Also 1.8.7 seems to be having some issues.

Zach Holman
Owner
holman commented

Next Play will be 1.9-only.

I'll try to take a look at this... well wait, not tomorrow. Thursday I should be in the office again.

Joey Wendt joeyw closed this
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
This page is out of date. Refresh to see the latest.
9 Rakefile
@@ -39,6 +39,15 @@ task :start do
39 39 Kernel.exec "bundle exec foreman start"
40 40 end
41 41
  42 +desc "Warm up song cache"
  43 +task :warm_cache do
  44 + # Cache all Song data.
  45 + songs = `osascript -e 'tell application "iTunes" to get persistent ID of every track'`.chomp.split(", ")
  46 + songs.each do |id|
  47 + Play::Song.find(id)
  48 + end
  49 +end
  50 +
42 51 namespace :redis do
43 52 desc "Wipe all data in redis"
44 53 task :reset do
23 app/api/system.rb
@@ -4,18 +4,25 @@ class App < Sinatra::Base
4 4
5 5 get "/images/art/:id.png" do
6 6 content_type 'image/png'
  7 + cached_artwork = "/tmp/play-artwork/#{params[:id]}.png"
7 8
8   - song = Song.find(params[:id])
9   - art = song.album_art_data if song
10   -
11   - if art
  9 + if File.exists? cached_artwork
12 10 response['Cache-Control'] = 'public, max-age=2500000'
13 11 etag params[:id]
14   - art
  12 + send_file cached_artwork, :disposition => 'inline'
15 13 else
16   - dir = File.dirname(File.expand_path(__FILE__))
17   - send_file "#{dir}/../frontend/public/images/art-placeholder.png",
18   - :disposition => 'inline'
  14 + song = Song.find(params[:id])
  15 + art = song.album_art_data if song
  16 +
  17 + if art
  18 + FileUtils.mkdir_p("/tmp/play-artwork")
  19 + File.write(cached_artwork, art)
  20 + art
  21 + else
  22 + dir = File.dirname(File.expand_path(__FILE__))
  23 + send_file "#{dir}/../frontend/public/images/art-placeholder.png",
  24 + :disposition => 'inline'
  25 + end
19 26 end
20 27 end
21 28
13 app/models/album.rb
@@ -22,8 +22,11 @@ def initialize(name,artist)
22 22 #
23 23 # Returns an Array of Songs.
24 24 def self.songs_by_name(name)
25   - Player.library.file_tracks[Appscript.its.album.eq(name)].get.map do |record|
26   - Song.new(record.persistent_ID.get)
  25 + songs = `osascript -e 'tell application "iTunes" to get persistent ID of every track whose album is \"#{name}\"'`.chomp.split(", ")
  26 + if songs.empty?
  27 + nil
  28 + else
  29 + songs.map { |id| Song.find(id) }
27 30 end
28 31 end
29 32
@@ -31,11 +34,7 @@ def self.songs_by_name(name)
31 34 #
32 35 # Returns an Array of Songs.
33 36 def songs
34   - Player.library.file_tracks[
35   - Appscript.its.album.eq(name).and(Appscript.its.artist.eq(artist))
36   - ].get.map do |record|
37   - Song.new(record.persistent_ID.get)
38   - end
  37 + Album.songs_by_name(self.name)
39 38 end
40 39
41 40 # Zips up an album and stashes in it a temporary directory.
9 app/models/artist.rb
@@ -17,12 +17,11 @@ def initialize(name)
17 17 #
18 18 # Returns an Array of Songs.
19 19 def songs
20   - if name
21   - Player.app.tracks[Appscript.its.artist.contains(name)].get.map do |record|
22   - Song.initialize_from_record(record)
23   - end
  20 + songs = `osascript -e 'tell application "iTunes" to get persistent ID of every track whose artist is \"#{self.name}\"'`.chomp.split(", ")
  21 + if songs.empty?
  22 + nil
24 23 else
25   - []
  24 + songs.map { |id| Song.find(id) }
26 25 end
27 26 end
28 27
76 app/models/player.rb
... ... @@ -1,50 +1,47 @@
1 1 module Play
2 2 class Player
3 3
4   - # The application we're using. iTunes, dummy.
5   - #
6   - # Returns an Appscript instance of the music app.
7   - def self.app
8   - Appscript.app('iTunes')
9   - end
10   -
11   - # All songs in the library.
12   - def self.library
13   - app.playlists['Library'].get
14   - end
15   -
16 4 # Play the music.
17 5 def self.play
18   - app.play
  6 + `osascript -e 'tell application "iTunes" to play'`
19 7 end
20 8
21 9 # Pause the music.
22 10 def self.pause
23   - app.pause
  11 + `osascript -e 'tell application "iTunes" to pause'`
  12 + end
  13 +
  14 + # Get current state.
  15 + #
  16 + # Returns symbol.
  17 + def self.state
  18 + `osascript -e 'tell application "iTunes" to get player state'`.chomp.to_sym
24 19 end
25 20
26 21 # Is there music currently playing?
27 22 def self.paused?
28   - state = app.player_state.get
29 23 state == :paused
30 24 end
31 25
32 26 # Maybe today is the day the music stopped.
33 27 def self.stop
34   - app.stop
  28 + `osascript -e 'tell application "iTunes" to stop'`
35 29 end
36 30
37 31 # Play the next song.
38 32 #
39 33 # Returns the new song.
40 34 def self.play_next
41   - app.next_track
  35 + `osascript -e 'tell application "iTunes" to play next track'`
42 36 now_playing
43 37 end
44 38
45 39 # Play the previous song.
  40 + #
  41 + # Returns the new song.
46 42 def self.play_previous
47   - app.previous_track
  43 + `osascript -e 'tell application "iTunes" to play previous track'`
  44 + now_playing
48 45 end
49 46
50 47 # Get the current numeric volume.
@@ -69,7 +66,7 @@ def self.system_volume=(setting)
69 66 #
70 67 # Returns an Integer from 0-100.
71 68 def self.app_volume
72   - app.sound_volume.get
  69 + `osascript -e 'tell application "iTunes" to get sound volume'`.chomp.to_i
73 70 end
74 71
75 72 # Set the app volume.
@@ -79,7 +76,7 @@ def self.app_volume
79 76 #
80 77 # Returns the current volume setting.
81 78 def self.app_volume=(setting)
82   - app.sound_volume.set(setting)
  79 + `osascript -e 'tell application "iTunes" to set sound volume to #{setting}'`
83 80 setting
84 81 end
85 82
@@ -94,13 +91,22 @@ def self.say(message)
94 91 self.app_volume = previous
95 92 end
96 93
  94 + # Get persistent id of current track.
  95 + #
  96 + # Returns string.
  97 + def self.current_track
  98 + `osascript -e 'tell application "iTunes" to get persistent ID of current track'`.chomp
  99 + end
  100 +
97 101 # Currently-playing song.
98 102 #
99 103 # Returns a Song.
100 104 def self.now_playing
101   - Song.new(app.current_track.persistent_ID.get)
102   - rescue Appscript::CommandError
103   - nil
  105 + if state == :playing
  106 + Song.find(current_track)
  107 + else
  108 + nil
  109 + end
104 110 end
105 111
106 112 # Search all songs for a keyword.
@@ -115,20 +121,28 @@ def self.now_playing
115 121 # Returns an Array of matching Songs.
116 122 def self.search(keyword)
117 123 # Exact Artist match.
118   - songs = library.tracks[Appscript.its.artist.eq(keyword)].get
119   - return songs.map{|record| Song.new(record.persistent_ID.get)} if songs.size != 0
  124 + songs = `osascript -e 'tell application "iTunes" to get persistent ID of every track whose artist is \"#{keyword}\"' 2>&1`.chomp.split(", ")
  125 + if $? == 0 && !songs.empty?
  126 + return songs.map { |id| Song.find(id) }
  127 + end
120 128
121 129 # Exact Album match.
122   - songs = library.tracks[Appscript.its.album.eq(keyword)].get
123   - return songs.map{|record| Song.new(record.persistent_ID.get)} if songs.size != 0
  130 + songs = `osascript -e 'tell application "iTunes" to get persistent ID of every track whose album is \"#{keyword}\"' 2>&1`.chomp.split(", ")
  131 + if $? == 0 && !songs.empty?
  132 + return songs.map { |id| Song.find(id) }
  133 + end
124 134
125 135 # Exact Song match.
126   - songs = library.tracks[Appscript.its.name.eq(keyword)].get
127   - return songs.map{|record| Song.new(record.persistent_ID.get)} if songs.size != 0
  136 + songs = `osascript -e 'tell application "iTunes" to get persistent ID of every track whose name is \"#{keyword}\"' 2>&1`.chomp.split(", ")
  137 + if $? == 0 && !songs.empty?
  138 + return songs.map { |id| Song.find(id) }
  139 + end
128 140
129 141 # Fuzzy Song match.
130   - songs = library.tracks[Appscript.its.name.contains(keyword)].get
131   - songs.map{|record| Song.new(record.persistent_ID.get)}
  142 + songs = `osascript -e 'tell application "iTunes" to get persistent ID of every track whose name contains \"#{keyword}\"' 2>&1`.chomp.split(", ")
  143 + if $? == 0 && !songs.empty?
  144 + return songs.map { |id| Song.find(id) }
  145 + end
132 146 end
133 147
134 148 end
52 app/models/queue.rb
@@ -11,15 +11,11 @@ def self.name
11 11 'iTunes DJ'
12 12 end
13 13
14   - # The Playlist object that the Queue resides in.
15   - #
16   - # Returns an Appscript::Reference to the Playlist.
17   - def self.playlist
18   - Player.app.playlists[name].get
19   - end
20   -
21 14 # Get the queue start offset for the iTunes DJ playlist.
22 15 #
  16 + # Bug: When player is stopped with no song, returns 0 resulting
  17 + # in queue including any song history from iTunes DJ.
  18 + #
23 19 # iTunes DJ keeps the current song in the playlist and
24 20 # x number of songs that have played. This method returns
25 21 # the current song index in the playlist. Using this we
@@ -33,7 +29,11 @@ def self.playlist
33 29 #
34 30 # Returns Integer offset to queued songs.
35 31 def self.playlist_offset
36   - Player.app.current_track.index.get
  32 + if [:playing, :paused].include?(Player.state)
  33 + `osascript -e 'tell application "iTunes" to get index of current track'`.chomp.to_i
  34 + else
  35 + 0
  36 + end
37 37 end
38 38
39 39 # Public: Adds a song to the Queue.
@@ -42,7 +42,7 @@ def self.playlist_offset
42 42 #
43 43 # Returns a Boolean of whether the song was added.
44 44 def self.add_song(song)
45   - Player.app.add(song.record.location.get, :to => playlist.get)
  45 + `osascript -e 'tell application "iTunes" to add add (get location of first track whose persistent ID is \"#{song.id}\") to playlist \"#{name}\"'`
46 46 end
47 47
48 48 # Public: Removes a song from the Queue.
@@ -51,16 +51,15 @@ def self.add_song(song)
51 51 #
52 52 # Returns a Boolean of whether the song was removed maybe.
53 53 def self.remove_song(song)
54   - Play::Queue.playlist.tracks[
55   - Appscript.its.persistent_ID.eq(song.id)
56   - ].first.delete
  54 + `osascript -e 'tell application "iTunes" to delete (first track of playlist \"#{name}\" whose persistent ID is \"#{song.id}\")'`
57 55 end
58 56
59 57 # Clear the queue. Shit's pretty destructive.
60 58 #
61   - # Returns who the fuck knows.
  59 + # Returns nil.
62 60 def self.clear
63   - Play::Queue.playlist.tracks.get.each { |record| record.delete }
  61 + `osascript -e 'tell application "iTunes" to delete (every track of playlist \"#{name}\")'`
  62 + nil
64 63 end
65 64
66 65 # Ensure that we're currently playing on the Play playlist. Don't let anyone
@@ -68,33 +67,32 @@ def self.clear
68 67 #
69 68 # Returns nil.
70 69 def self.ensure_playlist
71   - if Play::Player.app.current_playlist.get.name.get != name
72   - Play::Player.app.playlists[name].get.play
  70 + current_playlist = `osascript -e 'tell application "iTunes to get name of current playlist'`.chomp
  71 + if current_playlist != name
  72 + `osascript -e 'tell application "iTunes" to play playlist \"#{name}\"'`
73 73 end
74   - rescue Exception => e
75   - # just in case!
  74 + nil
76 75 end
77 76
78 77 # The songs currently in the Queue.
79 78 #
80 79 # Returns an Array of Songs.
81 80 def self.songs
82   - songs = playlist.tracks.get.map do |record|
83   - Song.find(record.persistent_ID.get)
  81 + songs = `osascript -e 'tell application "iTunes" to get persistent ID of every track of playlist \"#{name}\"'`.chomp.split(", ")
  82 + if songs.empty?
  83 + nil
  84 + else
  85 + songs.map! { |id| Song.find(id) }
  86 + songs.slice(playlist_offset, songs.length - playlist_offset)
84 87 end
85   - songs.slice(playlist_offset, songs.length - playlist_offset)
86   - rescue Exception => e
87   - # just in case!
88   - nil
89 88 end
90 89
91 90 # Is this song queued up to play?
92 91 #
93 92 # Returns a Boolean.
94 93 def self.queued?(song)
95   - Play::Queue.playlist.tracks[
96   - Appscript.its.persistent_ID.eq(song.id)
97   - ].get.size != 0
  94 + queued = `osascript -e 'tell application "iTunes" to get exists (first track of playlist \"#{name}\" whose persistent ID is \"#{song.id}\")'`.chomp.to_sym
  95 + queued == :true
98 96 end
99 97
100 98 # Returns the context of this Queue as JSON. This contains all of the songs
140 app/models/song.rb
@@ -27,37 +27,15 @@ class Song
27 27
28 28 # Initializes a new Song.
29 29 #
30   - # options - One of two possible arguments:
31   - # Song.new('2799A5071CD3E516') # creates from a persistent_id
32   - # Song.new(args) # `args` is a Hash of attributes
  30 + # attributes = Hash containing song attributes.
33 31 #
34 32 # Returns a new Song instance.
35   - def initialize(options)
36   - if options.kind_of?(String)
37   - song = Song.find(options)
38   - @id = song.id
39   - @name = song.name
40   - @artist = song.artist
41   - @album = song.album
42   - else
43   - @id = options[:id]
44   - @name = options[:name]
45   - @artist = options[:artist]
46   - @album = options[:album]
47   - @last_played = options[:last_played]
48   - end
49   - end
50   -
51   - # Optimization. This loads a new Song instance from an iTunes record. This
52   - # means we can bypass the expensive lookup and initialization inherant in
53   - # the #find method.
54   - #
55   - # Returns a new Song instance.
56   - def self.initialize_from_record(record)
57   - new :id => record.persistent_ID.get,
58   - :name => record.name.get,
59   - :artist => record.artist.get,
60   - :album => record.album.get
  33 + def initialize(attributes)
  34 + @id = attributes[:id]
  35 + @name = attributes[:name]
  36 + @artist = attributes[:artist]
  37 + @album = attributes[:album]
  38 + @last_played = attributes[:last_played]
61 39 end
62 40
63 41 # Finds a song in the database.
@@ -66,11 +44,50 @@ def self.initialize_from_record(record)
66 44 #
67 45 # Returns an instance of a Song.
68 46 def self.find(id)
69   - record = Player.library.tracks[Appscript.its.persistent_ID.eq(id)].get[0]
  47 + song_cache = $redis.hgetall id
  48 + if song_cache.empty?
  49 + applescript_find_by_id id
  50 + else
  51 + song = {
  52 + :id => id,
  53 + :name => song_cache["name"],
  54 + :artist => song_cache["artist"],
  55 + :album => song_cache["album"],
  56 + :last_played => song_cache["last_played"]
  57 + }
  58 + Song.new(song)
  59 + end
  60 + end
70 61
71   - return nil if record.nil?
  62 + def self.applescript_find_by_id(id)
  63 + attributes = `osascript -e 'tell application "iTunes" to get {persistent id, name, album, artist, duration} of (get first track whose persistent ID is \"#{id}\")'`.chomp.split(", ")
  64 + keys = [:id, :name, :album, :artist, :duration]
  65 + if !attributes.empty?
  66 + song = {}
  67 + keys.each_with_index do |k,i|
  68 + song[k] = process_value(attributes[i])
  69 + end
  70 +
  71 + last_played = `osascript -e 'tell application "iTunes" to get played date of (get first track whose persistent ID is \"#{id}\")'`.chomp
  72 + last_played = process_value(last_played)
  73 + if last_played != nil
  74 + last_played.slice!(0, 5)
  75 + song[:last_played] = last_played
  76 + end
  77 +
  78 + Song.new(song).save
  79 + end
  80 + end
72 81
73   - initialize_from_record(record)
  82 + # Public: Saves the Song.
  83 + #
  84 + # Returns Song self.
  85 + def save
  86 + $redis.hset id, 'name', name
  87 + $redis.hset id, 'artist', artist
  88 + $redis.hset id, 'album', album
  89 + $redis.hset id, 'last_played', last_played
  90 + self
74 91 end
75 92
76 93 # Find the most popular song by its name. Compares against playcount and
@@ -78,38 +95,32 @@ def self.find(id)
78 95 #
79 96 # Returns a Song.
80 97 def self.find_by_name(name)
81   - top = Player.library.tracks[Appscript.its.name.eq(name)].get.sort do |a,b|
82   - History.count_by_song(Song.new(:id => b.persistent_ID.get)) <=>
83   - History.count_by_song(Song.new(:id => a.persistent_ID.get))
84   - end[0]
  98 + songs = `osascript -e 'tell application "iTunes" to get persistent ID of every track whose name is \"#{name}\"'`.chomp.split(", ")
  99 + if songs.empty?
  100 + return nil
  101 + end
85 102
86   - if top
87   - find(top.get.persistent_ID.get)
  103 + top = songs.sort do |a, b|
  104 + History.count_by_song(Song.find(b)) <=> History.count_by_song(Song.find(a))
88 105 end
89   - end
90 106
91   - # The Appscript record.
92   - #
93   - # Returns an Appscript::Reference to this song.
94   - #
95   - # If we have an ID for this song, let's use that to find it (preferred,
96   - # since that'll be quick). If not, search through a combination Artist +
97   - # Song name match and return that record.
98   - def record
99   - if id
100   - Player.library.tracks[Appscript.its.persistent_ID.eq(id)].get[0]
101   - else
102   - song = Artist.new(artist).songs.select{|song| song.name =~ /^#{Regexp.escape(name)}$/i}.first
103   - song.record if song
  107 + if top.first
  108 + find(top.first)
104 109 end
105 110 end
106 111
107 112 # The raw data of the album art provided for this song.
108 113 #
109   - # Returns a String of the binary image or some shit. If there's no art
110   - # available, returns nil.
  114 + # Returns String of jpeg binary data as hex (high nimble bit first) string.
111 115 def album_art_data
112   - record.artworks.get.empty? ? nil : record.artworks[1].raw_data.get.data
  116 + image_data = `osascript -e 'tell application "iTunes" to get raw data of artwork 1 of (get first track whose persistent ID is "#{self.id}")' 2>&1`.chomp
  117 + if $? == 0
  118 + image_data.slice!(0, 10)
  119 + image_data.chop!
  120 + [image_data].pack('H*')
  121 + else
  122 + nil
  123 + end
113 124 end
114 125
115 126 # The playcount for this song.
@@ -137,7 +148,7 @@ def queued?
137 148 #
138 149 # Returns a String.
139 150 def path
140   - record.location.get.to_s
  151 + `osascript -e 'tell application "iTunes" to get POSIX path of (get location of first track whose persistent ID is \"#{self.id}\")'`.chomp
141 152 end
142 153
143 154 def last_played
@@ -145,9 +156,11 @@ def last_played
145 156 end
146 157
147 158 def last_played_iso8601
148   - ret = last_played
149   - return "" unless ret
150   - return ret.iso8601
  159 + if last_played && last_played != ""
  160 + Time.parse(last_played).iso8601
  161 + else
  162 + nil
  163 + end
151 164 end
152 165
153 166 # The hashed representation of a Song, suitable for API responses.
@@ -165,5 +178,14 @@ def to_hash
165 178 }
166 179 end
167 180
  181 + # Convert applescript missing value to nils.
  182 + #
  183 + # Value - string applescript property.
  184 + #
  185 + # Returns value or nil.
  186 + def self.process_value(value)
  187 + value == "missing value" ? nil : value
  188 + end
  189 +
168 190 end
169 191 end
2  script/realtime
@@ -6,7 +6,7 @@
6 6
7 7 $PROGRAM_NAME = 'play [realtime-daemon]'
8 8
9   -require 'app/boot'
  9 +require './app/boot'
10 10
11 11 class Realtime
12 12 def initialize

Tip: You can add notes to lines in a file. Hover to the left of a line to make a note

Something went wrong with that request. Please try again.