source: trunk/StreamVision/StreamVision.py@ 650

Last change on this file since 650 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
RevLine 
[188]1#!/usr/bin/pythonw
2# -*- coding: utf-8 -*-
3
[232]4from appscript import app, k, its, CommandError
[300]5from AppKit import NSApplication, NSApplicationDefined, NSBeep, NSSystemDefined, NSURL, NSWorkspace
[486]6from Foundation import NSDistributedNotificationCenter, NSSearchPathForDirectoriesInDomains, NSCachesDirectory, NSUserDomainMask
[188]7from PyObjCTools import AppHelper
8from Carbon.CarbonEvt import RegisterEventHotKey, GetApplicationEventTarget
[232]9from Carbon.Events import cmdKey, shiftKey, controlKey
[486]10import httplib2
11import os
[188]12import struct
13import scrape
[211]14import HotKey
[649]15import tunes # <https://github.com/abarnert/itunesterms>
[188]16
17GROWL_APP_NAME = 'StreamVision'
18NOTIFICATION_TRACK_INFO = 'iTunes Track Info'
19NOTIFICATIONS_ALL = [NOTIFICATION_TRACK_INFO]
20
21kEventHotKeyPressedSubtype = 6
22kEventHotKeyReleasedSubtype = 9
23
[300]24kHIDUsage_Csmr_ScanNextTrack = 0xB5
25kHIDUsage_Csmr_ScanPreviousTrack = 0xB6
26kHIDUsage_Csmr_PlayOrPause = 0xCD
27
[340]28def growlRegister():
29 global growl
30 growl = app(id='com.Growl.GrowlHelperApp')
[188]31
[340]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
[188]38
39def growlNotify(title, description, **kw):
[340]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)
[195]50
[188]51def radioParadiseURL():
52 session = scrape.Session()
[645]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
[188]58
[194]59def cleanStreamTitle(title):
[316]60 if title == k.missing_value:
[194]61 return ''
62 title = title.split(' [')[0] # XXX move to description
[341]63 try: # incorrectly encoded?
64 title = title.encode('iso-8859-1').decode('utf-8')
[459]65 except (UnicodeDecodeError, UnicodeEncodeError):
[341]66 pass
[194]67 title = title.replace('`', u'’')
[323]68 return title
[195]69
[194]70def cleanStreamTrackName(name):
[188]71 name = name.split('. ')[0]
72 name = name.split(': ')[0]
[190]73 name = name.split(' - ')
74 if len(name) > 1:
75 name = ' - '.join(name[:-1])
76 else:
77 name = name[0]
[188]78 return name
[195]79
[649]80def iTunesApp(): return app(id='com.apple.iTunes', terms=tunes)
[199]81def XTensionApp(): return app(creator='SHEx')
[414]82def AmuaApp(): return app('Amua.app')
[188]83
[199]84HAVE_XTENSION = False
85try:
86 XTensionApp()
87 HAVE_XTENSION = True
88except:
89 pass
90
[414]91HAVE_AMUA = False
92try:
93 AmuaApp()
94 HAVE_AMUA = True
95except:
96 pass
97
[324]98needsStereoPowerOn = HAVE_XTENSION
99
[580]100def mayUseStereo():
101 if not HAVE_XTENSION:
102 return False
103 systemEvents = app(id='com.apple.systemEvents')
[632]104 iTunesWindow = systemEvents.application_processes[u'iTunes'].windows[u'iTunes']
[646]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
[638]110 return remote_speakers and remote_speakers[0] not in (None, k.missing_value)
[580]111
112def turnStereoOn():
113 global needsStereoPowerOn
114 if not mayUseStereo():
[631]115 if HAVE_XTENSION and XTensionApp().status('Stereo'):
116 XTensionApp().turnoff('Stereo')
[580]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
[414]130def amuaPlaying():
131 if not HAVE_AMUA:
132 return False
133 return AmuaApp().is_playing()
134
[486]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
[188]157class StreamVision(NSApplication):
158
159 hotKeyActions = {}
160 hotKeys = []
161
[414]162 def displayTrackInfo(self, playerInfo=None):
[188]163 iTunes = iTunesApp()
[195]164
[497]165 try:
166 trackClass = iTunes.current_track.class_()
167 except CommandError:
168 trackClass = k.property
169
[211]170 trackName = ''
[315]171 if trackClass != k.property:
172 trackName = iTunes.current_track.name()
[211]173
[647]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)
[580]181 turnStereoOff()
[211]182 return
[580]183 turnStereoOn()
[211]184 if trackClass == k.URL_track:
[414]185 if amuaPlaying():
186 if playerInfo is None: # Amua displays it itself
187 AmuaApp().display_song_information()
188 return
[486]189 url = iTunes.current_stream_URL()
190 kw = {}
191 if url != k.missing_value and url.endswith('.jpg'):
[650]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
[315]201 growlNotify(cleanStreamTitle(iTunes.current_stream_title()),
[486]202 cleanStreamTrackName(trackName), **kw)
[211]203 return
[315]204 if trackClass == k.property:
[324]205 growlNotify('iTunes is playing.', '')
206 return
[211]207 kw = {}
208 # XXX iTunes doesn't let you get artwork for shared tracks
209 if trackClass != k.shared_track:
[315]210 artwork = iTunes.current_track.artworks()
[211]211 if artwork:
[487]212 try:
[636]213 kw['pictImage'] = artwork[0].data_()
[487]214 except CommandError:
215 pass
[211]216 growlNotify(trackName + ' ' +
[323]217 u'★' * (iTunes.current_track.rating() / 20),
218 iTunes.current_track.album() + '\n' +
[315]219 iTunes.current_track.artist(),
[211]220 **kw)
221
[188]222 def goToSite(self):
223 iTunes = iTunesApp()
[315]224 if iTunes.player_state() == k.playing:
[414]225 if amuaPlaying():
226 AmuaApp().display_album_details()
227 return
[315]228 url = iTunes.current_stream_URL()
[316]229 if url != k.missing_value:
[252]230 if 'radioparadise.com' in url and 'review' not in url:
[188]231 url = radioParadiseURL()
232 NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(url))
233 return
234 NSBeep()
[195]235
[188]236 def registerHotKey(self, func, keyCode, mods=0):
237 hotKeyRef = RegisterEventHotKey(keyCode, mods, (0, 0),
238 GetApplicationEventTarget(), 0)
239 self.hotKeys.append(hotKeyRef)
[211]240 self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)] = func
[235]241 return hotKeyRef
[188]242
[235]243 def unregisterHotKey(self, hotKeyRef):
244 self.hotKeys.remove(hotKeyRef)
245 del self.hotKeyActions[HotKey.HotKeyAddress(hotKeyRef)]
246 hotKeyRef.UnregisterEventHotKey()
247
[199]248 def incrementRatingBy(self, increment):
249 iTunes = iTunesApp()
[414]250 if amuaPlaying():
[415]251 if increment < 0:
[414]252 AmuaApp().ban_song()
[458]253 growlNotify('Banned song.', '', icon_of_application='Amua.app')
[414]254 else:
255 AmuaApp().love_song()
[458]256 growlNotify('Loved song.', '', icon_of_application='Amua.app')
[414]257 return
[315]258 rating = iTunes.current_track.rating()
[199]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
[300]268 def playPause(self, useStereo=True):
[324]269 global needsStereoPowerOn
270
[199]271 iTunes = iTunesApp()
[315]272 was_playing = (iTunes.player_state() == k.playing)
[324]273 if not useStereo:
274 needsStereoPowerOn = False
[414]275 if was_playing and amuaPlaying():
276 AmuaApp().stop()
277 else:
278 iTunes.playpause()
[315]279 if not was_playing and iTunes.player_state() == k.stopped:
[234]280 # most likely, we're focused on the iPod, so playing does nothing
[315]281 iTunes.browser_windows[1].view.set(iTunes.user_playlists[its.name=='Stations'][1]())
[234]282 iTunes.play()
[580]283 if not useStereo:
284 return
285 if iTunes.player_state() == k.playing:
286 turnStereoOn()
287 else:
288 turnStereoOff()
[235]289
[301]290 def playPauseFront(self):
291 systemEvents = app(id='com.apple.systemEvents')
[340]292 frontName = systemEvents.processes[its.frontmost == True][1].name()
[301]293 if frontName == 'RealPlayer':
294 realPlayer = app(id='com.RealNetworks.RealPlayer')
[315]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]301 elif frontName == 'VLC':
302 app(id='org.videolan.vlc').play() # equivalent to playpause
303 else:
[302]304 self.playPause(useStereo=False)
[301]305
[414]306 def nextTrack(self):
307 if amuaPlaying():
308 AmuaApp().skip_song()
309 return
310 iTunesApp().next_track()
311
[235]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
[232]319 def zoomWindow(self):
[313]320 # XXX detect if "enable access for assistive devices" needs to be enabled
[232]321 systemEvents = app(id='com.apple.systemEvents')
[340]322 frontName = systemEvents.processes[its.frontmost == True][1].name()
[233]323 if frontName == 'iTunes':
[232]324 systemEvents.processes['iTunes'].menu_bars[1]. \
325 menu_bar_items['Window'].menus.menu_items['Zoom'].click()
326 return
[631]327 elif frontName in ('X11', 'XQuartz', 'Emacs'): # preserve C-M-\
[235]328 self.unregisterZoomWindowHotKey()
329 systemEvents.key_code(42, using=[k.command_down, k.control_down])
330 self.registerZoomWindowHotKey()
331 return
[340]332 frontPID = systemEvents.processes[its.frontmost == True][1].unix_id()
[232]333 try:
[339]334 zoomed = app(pid=frontPID).windows[1].zoomed
[232]335 zoomed.set(not zoomed())
[235]336 except (CommandError, RuntimeError):
[313]337 systemEvents.processes[frontName].windows \
338 [its.subrole == 'AXStandardWindow'].windows[1]. \
339 buttons[its.subrole == 'AXZoomButton'].buttons[1].click()
[199]340
[188]341 def finishLaunching(self):
342 super(StreamVision, self).finishLaunching()
[486]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
[188]350 self.registerHotKey(self.displayTrackInfo, 100) # F8
351 self.registerHotKey(self.goToSite, 100, cmdKey) # cmd-F8
[199]352 self.registerHotKey(self.playPause, 101) # F9
[188]353 self.registerHotKey(lambda: iTunesApp().previous_track(), 109) # F10
[414]354 self.registerHotKey(self.nextTrack, 103) # F11
[211]355 self.registerHotKey(lambda: self.incrementRatingBy(-20), 109, shiftKey) # shift-F10
356 self.registerHotKey(lambda: self.incrementRatingBy(20), 103, shiftKey) # shift-F11
[235]357 self.registerZoomWindowHotKey()
[193]358 NSDistributedNotificationCenter.defaultCenter().addObserver_selector_name_object_(self, self.displayTrackInfo, 'com.apple.iTunes.playerInfo', None)
[300]359 try:
[302]360 import HIDRemote
[300]361 HIDRemote.connect()
[302]362 except ImportError:
363 print "failed to import HIDRemote (XXX fix - on Intel)"
[300]364 except OSError, e:
365 print "failed to connect to remote: ", e
[188]366
367 def sendEvent_(self, theEvent):
[300]368 eventType = theEvent.type()
369 if eventType == NSSystemDefined and \
[188]370 theEvent.subtype() == kEventHotKeyPressedSubtype:
371 self.hotKeyActions[theEvent.data1()]()
[300]372 elif eventType == NSApplicationDefined:
373 key = theEvent.data1()
374 if key == kHIDUsage_Csmr_ScanNextTrack:
[415]375 self.nextTrack()
[300]376 elif key == kHIDUsage_Csmr_ScanPreviousTrack:
377 iTunesApp().previous_track()
378 elif key == kHIDUsage_Csmr_PlayOrPause:
[301]379 self.playPauseFront()
[188]380 super(StreamVision, self).sendEvent_(theEvent)
381
382if __name__ == "__main__":
[340]383 growlRegister()
[195]384 AppHelper.runEventLoop()
[339]385 try:
386 HIDRemote.disconnect()
387 except:
388 pass
Note: See TracBrowser for help on using the repository browser.