source: trunk/StreamVision/StreamVision.py @ 650

Last change on this file since 650 was 650, checked in by Nicholas Riley, 7 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.