source: trunk/StreamVision/StreamVision.py @ 647

Last change on this file since 647 was 647, checked in by Nicholas Riley, 7 years ago

StreamVision?: Be resilient to iTunes quitting, finally.

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