source: trunk/StreamVision/StreamVision.py@ 651

Last change on this file since 651 was 650, checked in by Nicholas Riley, 11 years ago

StreamVision.py: Don't fail on socket errors when retrieving album art.

This requires updating to latest Mercurial version of httplib2; the
older version I was using did not deal with timeouts and socket errors
very well.

File size: 13.3 KB
Line 
1#!/usr/bin/pythonw
2# -*- coding: utf-8 -*-
3
4from appscript import app, k, its, CommandError
5from AppKit import NSApplication, NSApplicationDefined, NSBeep, NSSystemDefined, NSURL, NSWorkspace
6from Foundation import NSDistributedNotificationCenter, NSSearchPathForDirectoriesInDomains, NSCachesDirectory, NSUserDomainMask
7from PyObjCTools import AppHelper
8from Carbon.CarbonEvt import RegisterEventHotKey, GetApplicationEventTarget
9from Carbon.Events import cmdKey, shiftKey, controlKey
10import httplib2
11import os
12import struct
13import scrape
14import HotKey
15import tunes # <https://github.com/abarnert/itunesterms>
16
17GROWL_APP_NAME = 'StreamVision'
18NOTIFICATION_TRACK_INFO = 'iTunes Track Info'
19NOTIFICATIONS_ALL = [NOTIFICATION_TRACK_INFO]
20
21kEventHotKeyPressedSubtype = 6
22kEventHotKeyReleasedSubtype = 9
23
24kHIDUsage_Csmr_ScanNextTrack = 0xB5
25kHIDUsage_Csmr_ScanPreviousTrack = 0xB6
26kHIDUsage_Csmr_PlayOrPause = 0xCD
27
28def growlRegister():
29 global growl
30 growl = app(id='com.Growl.GrowlHelperApp')
31
32 growl.register(
33 as_application=GROWL_APP_NAME,
34 all_notifications=NOTIFICATIONS_ALL,
35 default_notifications=NOTIFICATIONS_ALL,
36 icon_of_application='iTunes.app')
37 # if we leave off the .app, we can get Classic iTunes's icon
38
39def growlNotify(title, description, **kw):
40 try:
41 growl.notify(
42 with_name=NOTIFICATION_TRACK_INFO,
43 title=title,
44 description=description,
45 application_name=GROWL_APP_NAME,
46 **kw)
47 except CommandError:
48 growlRegister()
49 growlNotify(title, description, **kw)
50
51def radioParadiseURL():
52 session = scrape.Session()
53 session.go('http://radioparadise.com/jq_playlist.php')
54 url = session.region.firsttag('a')['href']
55 if not url.startswith('http'):
56 url = 'http://www.radioparadise.com/rp2-' + url
57 return url
58
59def cleanStreamTitle(title):
60 if title == k.missing_value:
61 return ''
62 title = title.split(' [')[0] # XXX move to description
63 try: # incorrectly encoded?
64 title = title.encode('iso-8859-1').decode('utf-8')
65 except (UnicodeDecodeError, UnicodeEncodeError):
66 pass
67 title = title.replace('`', u'’')
68 return title
69
70def cleanStreamTrackName(name):
71 name = name.split('. ')[0]
72 name = name.split(': ')[0]
73 name = name.split(' - ')
74 if len(name) > 1:
75 name = ' - '.join(name[:-1])
76 else:
77 name = name[0]
78 return name
79
80def iTunesApp(): return app(id='com.apple.iTunes', terms=tunes)
81def XTensionApp(): return app(creator='SHEx')
82def AmuaApp(): return app('Amua.app')
83
84HAVE_XTENSION = False
85try:
86 XTensionApp()
87 HAVE_XTENSION = True
88except:
89 pass
90
91HAVE_AMUA = False
92try:
93 AmuaApp()
94 HAVE_AMUA = True
95except:
96 pass
97
98needsStereoPowerOn = HAVE_XTENSION
99
100def mayUseStereo():
101 if not HAVE_XTENSION:
102 return False
103 systemEvents = app(id='com.apple.systemEvents')
104 iTunesWindow = systemEvents.application_processes[u'iTunes'].windows[u'iTunes']
105 # Can't get AirPlay status with iTunes Mini Player or window on other Space.
106 try:
107 remote_speakers = iTunesWindow.buttons[its.attributes['AXDescription'].value.beginswith(u'AirPlay')].title()
108 except CommandError: # window on another Space?
109 return True
110 return remote_speakers and remote_speakers[0] not in (None, k.missing_value)
111
112def turnStereoOn():
113 global needsStereoPowerOn
114 if not mayUseStereo():
115 if HAVE_XTENSION and XTensionApp().status('Stereo'):
116 XTensionApp().turnoff('Stereo')
117 return
118 if not XTensionApp().status('Stereo'):
119 XTensionApp().turnon('Stereo')
120 needsStereoPowerOn = False
121
122def turnStereoOff():
123 global needsStereoPowerOn
124 if not mayUseStereo():
125 return
126 if not needsStereoPowerOn and XTensionApp().status('Stereo'):
127 XTensionApp().turnoff('Stereo')
128 needsStereoPowerOn = True
129
130def amuaPlaying():
131 if not HAVE_AMUA:
132 return False
133 return AmuaApp().is_playing()
134
135class OneFileCache(object):
136 __slots__ = ('key', 'cache')
137
138 def __init__(self, cache):
139 if not os.path.exists(cache):
140 os.makedirs(cache)
141 self.cache = os.path.join(cache, 'file')
142 self.key = None
143
144 def get(self, key):
145 if key == self.key:
146 return file(self.cache, 'r').read()
147
148 def set(self, key, value):
149 self.key = key
150 file(self.cache, 'w').write(value)
151
152 def delete(self, key):
153 if key == self.key:
154 self.key = None
155 os.remove(cache)
156
157class StreamVision(NSApplication):
158
159 hotKeyActions = {}
160 hotKeys = []
161
162 def displayTrackInfo(self, playerInfo=None):
163 iTunes = iTunesApp()
164
165 try:
166 trackClass = iTunes.current_track.class_()
167 except CommandError:
168 trackClass = k.property
169
170 trackName = ''
171 if trackClass != k.property:
172 trackName = iTunes.current_track.name()
173
174 try:
175 playerState = iTunes.player_state()
176 except CommandError:
177 playerState = None # probably iTunes quit
178 if playerState != k.playing:
179 if playerState != None:
180 growlNotify('iTunes is not playing.', trackName)
181 turnStereoOff()
182 return
183 turnStereoOn()
184 if trackClass == k.URL_track:
185 if amuaPlaying():
186 if playerInfo is None: # Amua displays it itself
187 AmuaApp().display_song_information()
188 return
189 url = iTunes.current_stream_URL()
190 kw = {}
191 if url != k.missing_value and url.endswith('.jpg'):
192 try:
193 response, content = self.http.request(url)
194 except Exception, e:
195 import sys
196 print >> sys.stderr, 'Request for album art failed:', e
197 else:
198 if response['content-type'].startswith('image/'):
199 file(self.imagePath, 'w').write(content)
200 kw['image_from_location'] = self.imagePath
201 growlNotify(cleanStreamTitle(iTunes.current_stream_title()),
202 cleanStreamTrackName(trackName), **kw)
203 return
204 if trackClass == k.property:
205 growlNotify('iTunes is playing.', '')
206 return
207 kw = {}
208 # XXX iTunes doesn't let you get artwork for shared tracks
209 if trackClass != k.shared_track:
210 artwork = iTunes.current_track.artworks()
211 if artwork:
212 try:
213 kw['pictImage'] = artwork[0].data_()
214 except CommandError:
215 pass
216 growlNotify(trackName + ' ' +
217 u'★' * (iTunes.current_track.rating() / 20),
218 iTunes.current_track.album() + '\n' +
219 iTunes.current_track.artist(),
220 **kw)
221
222 def goToSite(self):
223 iTunes = iTunesApp()
224 if iTunes.player_state() == k.playing:
225 if amuaPlaying():
226 AmuaApp().display_album_details()
227 return
228 url = iTunes.current_stream_URL()
229 if url != k.missing_value:
230 if 'radioparadise.com' in url and 'review' not in url:
231 url = radioParadiseURL()
232 NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(url))
233 return
234 NSBeep()
235
236 def registerHotKey(self, func, keyCode, mods=0):
237 hotKeyRef = RegisterEventHotKey(keyCode, mods, (0, 0),
238 GetApplicationEventTarget(), 0)
239 self.hotKeys.append(hotKeyRef)
240 self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)] = func
241 return hotKeyRef
242
243 def unregisterHotKey(self, hotKeyRef):
244 self.hotKeys.remove(hotKeyRef)
245 del self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)]
246 hotKeyRef.UnregisterEventHotKey()
247
248 def incrementRatingBy(self, increment):
249 iTunes = iTunesApp()
250 if amuaPlaying():
251 if increment < 0:
252 AmuaApp().ban_song()
253 growlNotify('Banned song.', '', icon_of_application='Amua.app')
254 else:
255 AmuaApp().love_song()
256 growlNotify('Loved song.', '', icon_of_application='Amua.app')
257 return
258 rating = iTunes.current_track.rating()
259 rating += increment
260 if rating < 0:
261 rating = 0
262 NSBeep()
263 elif rating > 100:
264 rating = 100
265 NSBeep()
266 iTunes.current_track.rating.set(rating)
267
268 def playPause(self, useStereo=True):
269 global needsStereoPowerOn
270
271 iTunes = iTunesApp()
272 was_playing = (iTunes.player_state() == k.playing)
273 if not useStereo:
274 needsStereoPowerOn = False
275 if was_playing and amuaPlaying():
276 AmuaApp().stop()
277 else:
278 iTunes.playpause()
279 if not was_playing and iTunes.player_state() == k.stopped:
280 # most likely, we're focused on the iPod, so playing does nothing
281 iTunes.browser_windows[1].view.set(iTunes.user_playlists[its.name=='Stations'][1]())
282 iTunes.play()
283 if not useStereo:
284 return
285 if iTunes.player_state() == k.playing:
286 turnStereoOn()
287 else:
288 turnStereoOff()
289
290 def playPauseFront(self):
291 systemEvents = app(id='com.apple.systemEvents')
292 frontName = systemEvents.processes[its.frontmost == True][1].name()
293 if frontName == 'RealPlayer':
294 realPlayer = app(id='com.RealNetworks.RealPlayer')
295 if len(realPlayer.players()) > 0:
296 if realPlayer.players[1].state() == k.playing:
297 realPlayer.pause()
298 else:
299 realPlayer.play()
300 return
301 elif frontName == 'VLC':
302 app(id='org.videolan.vlc').play() # equivalent to playpause
303 else:
304 self.playPause(useStereo=False)
305
306 def nextTrack(self):
307 if amuaPlaying():
308 AmuaApp().skip_song()
309 return
310 iTunesApp().next_track()
311
312 def registerZoomWindowHotKey(self):
313 self.zoomWindowHotKey = self.registerHotKey(self.zoomWindow, 42, cmdKey | controlKey) # cmd-ctrl-\
314
315 def unregisterZoomWindowHotKey(self):
316 self.unregisterHotKey(self.zoomWindowHotKey)
317 self.zoomWindowHotKey = None
318
319 def zoomWindow(self):
320 # XXX detect if "enable access for assistive devices" needs to be enabled
321 systemEvents = app(id='com.apple.systemEvents')
322 frontName = systemEvents.processes[its.frontmost == True][1].name()
323 if frontName == 'iTunes':
324 systemEvents.processes['iTunes'].menu_bars[1]. \
325 menu_bar_items['Window'].menus.menu_items['Zoom'].click()
326 return
327 elif frontName in ('X11', 'XQuartz', 'Emacs'): # preserve C-M-\
328 self.unregisterZoomWindowHotKey()
329 systemEvents.key_code(42, using=[k.command_down, k.control_down])
330 self.registerZoomWindowHotKey()
331 return
332 frontPID = systemEvents.processes[its.frontmost == True][1].unix_id()
333 try:
334 zoomed = app(pid=frontPID).windows[1].zoomed
335 zoomed.set(not zoomed())
336 except (CommandError, RuntimeError):
337 systemEvents.processes[frontName].windows \
338 [its.subrole == 'AXStandardWindow'].windows[1]. \
339 buttons[its.subrole == 'AXZoomButton'].buttons[1].click()
340
341 def finishLaunching(self):
342 super(StreamVision, self).finishLaunching()
343
344 caches = NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
345 NSUserDomainMask, True)[0]
346 cache = os.path.join(caches, 'StreamVision')
347 self.http = httplib2.Http(OneFileCache(cache), 5)
348 self.imagePath = os.path.join(cache, 'image')
349
350 self.registerHotKey(self.displayTrackInfo, 100) # F8
351 self.registerHotKey(self.goToSite, 100, cmdKey) # cmd-F8
352 self.registerHotKey(self.playPause, 101) # F9
353 self.registerHotKey(lambda: iTunesApp().previous_track(), 109) # F10
354 self.registerHotKey(self.nextTrack, 103) # F11
355 self.registerHotKey(lambda: self.incrementRatingBy(-20), 109, shiftKey) # shift-F10
356 self.registerHotKey(lambda: self.incrementRatingBy(20), 103, shiftKey) # shift-F11
357 self.registerZoomWindowHotKey()
358 NSDistributedNotificationCenter.defaultCenter().addObserver_selector_name_object_(self, self.displayTrackInfo, 'com.apple.iTunes.playerInfo', None)
359 try:
360 import HIDRemote
361 HIDRemote.connect()
362 except ImportError:
363 print "failed to import HIDRemote (XXX fix - on Intel)"
364 except OSError, e:
365 print "failed to connect to remote: ", e
366
367 def sendEvent_(self, theEvent):
368 eventType = theEvent.type()
369 if eventType == NSSystemDefined and \
370 theEvent.subtype() == kEventHotKeyPressedSubtype:
371 self.hotKeyActions[theEvent.data1()]()
372 elif eventType == NSApplicationDefined:
373 key = theEvent.data1()
374 if key == kHIDUsage_Csmr_ScanNextTrack:
375 self.nextTrack()
376 elif key == kHIDUsage_Csmr_ScanPreviousTrack:
377 iTunesApp().previous_track()
378 elif key == kHIDUsage_Csmr_PlayOrPause:
379 self.playPauseFront()
380 super(StreamVision, self).sendEvent_(theEvent)
381
382if __name__ == "__main__":
383 growlRegister()
384 AppHelper.runEventLoop()
385 try:
386 HIDRemote.disconnect()
387 except:
388 pass
Note: See TracBrowser for help on using the repository browser.