~josephjamesmills/beta/unity-scope-mythtv

« back to all changes in this revision

Viewing changes to src/unity-scope-mythtv-ng

  • Committer: Thomas Mashos
  • Date: 2012-10-19 21:17:40 UTC
  • Revision ID: thomas@mashos.com-20121019211740-fhjwyo4me40z5a2b
Added setup.py file for installation. Added test scope for testing (from remote-videos scope)

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
#! /usr/bin/python
 
2
# -*- coding: utf-8 -*-
 
3
 
 
4
#    Copyright (c) 2011 David Calle <davidc@framli.eu>
 
5
 
 
6
#    This program is free software: you can redistribute it and/or modify
 
7
#    it under the terms of the GNU General Public License as published by
 
8
#    the Free Software Foundation, either version 3 of the License, or
 
9
#    (at your option) any later version.
 
10
 
 
11
#    This program is distributed in the hope that it will be useful,
 
12
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
 
13
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
14
#    GNU General Public License for more details.
 
15
 
 
16
#    You should have received a copy of the GNU General Public License
 
17
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
18
 
 
19
"""The unity remote videos scope."""
 
20
 
 
21
import json
 
22
import sys
 
23
from urllib import urlencode
 
24
import time
 
25
from datetime import datetime
 
26
import gettext
 
27
 
 
28
#pylint: disable=E0611
 
29
from zeitgeist.client import ZeitgeistClient
 
30
from zeitgeist.datamodel import (
 
31
    Event, 
 
32
    Subject, 
 
33
    Interpretation, 
 
34
    Manifestation,
 
35
)
 
36
 
 
37
from gi.repository import (
 
38
    GLib,
 
39
    GObject,
 
40
    Gio,
 
41
    Unity,
 
42
    Soup,
 
43
    SoupGNOME,
 
44
)
 
45
#pylint: enable=E0611
 
46
 
 
47
APP_NAME = "unity-lens-video"
 
48
LOCAL_PATH = "/usr/share/locale/"
 
49
 
 
50
gettext.bindtextdomain(APP_NAME, LOCAL_PATH)
 
51
gettext.textdomain(APP_NAME)
 
52
_ = gettext.gettext
 
53
n_ = gettext.ngettext
 
54
 
 
55
CAT_INDEX_ONLINE = 1
 
56
CAT_INDEX_MORE = 2
 
57
 
 
58
BUS_NAME = "net.launchpad.scope.RemoteVideos"
 
59
SERVER = "http://videosearch.ubuntu.com/v0"
 
60
 
 
61
REFRESH_INTERVAL = 3600         # fetch sources & recommendations once an hour
 
62
RETRY_INTERVAL = 60             # retry sources/recommendations after a minute
 
63
 
 
64
# Be cautious with Zeitgeist, don't assume it's ready
 
65
try:
 
66
    ZGCLIENT = ZeitgeistClient()
 
67
    # Creation of the Zg data source
 
68
    ZGCLIENT.register_data_source("98898", "Unity Videos lens",
 
69
        "",[Event.new_for_values(actor="lens://unity-lens-video")])
 
70
except:
 
71
    print "Unable to connect to Zeitgeist, won't send events."
 
72
    ZGCLIENT = None
 
73
 
 
74
class Daemon:
 
75
 
 
76
    """Create the scope and listen to Unity signals"""
 
77
 
 
78
    def __init__(self):
 
79
        self._pending = None
 
80
        self.sources_list = []
 
81
        self.recommendations = []
 
82
        self.scope = Unity.Scope.new("/net/launchpad/scope/remotevideos")
 
83
        self.scope.search_in_global = False
 
84
        self.scope.connect("search-changed", self.on_search_changed)
 
85
        self.scope.connect("filters-changed", self.on_filtering_or_preference_changed)
 
86
        self.scope.props.sources.connect("notify::filtering",
 
87
            self.on_filtering_or_preference_changed)
 
88
        self.scope.connect("activate-uri", self.on_activate_uri)
 
89
        self.scope.connect("preview-uri", self.on_preview_uri)
 
90
        self.scope.connect("generate-search-key", lambda scope, search: search.props.search_string.strip())
 
91
        self.session = Soup.SessionAsync()
 
92
        self.session.props.user_agent = "Unity Video Lens Remote Scope v0.4"
 
93
        self.session.add_feature_by_type(SoupGNOME.ProxyResolverGNOME)
 
94
        self.query_list_of_sources()
 
95
        self.update_recommendations()
 
96
        self.preferences = Unity.PreferencesManager.get_default()
 
97
        self.preferences.connect("notify::remote-content-search", self.on_filtering_or_preference_changed)
 
98
        self.scope.export()
 
99
        # refresh the at least once every 30 minutes
 
100
        GLib.timeout_add_seconds(REFRESH_INTERVAL/2, self.refresh_online_videos)
 
101
 
 
102
    def update_recommendations(self):
 
103
        """Query the server for 'recommendations'.
 
104
 
 
105
        In v0 extended, that means simply do an empty search with Amazon
 
106
        as the source since it's the only paid provider at the moment.
 
107
        """
 
108
        msg = Soup.Message.new("GET", SERVER + "/search?q=&sources=Amazon")
 
109
        self.session.queue_message(msg, self._recs_cb, None)
 
110
 
 
111
    def _recs_cb(self, session, msg, user_data):
 
112
        recs = self._handle_search_msg(msg)
 
113
        print "Got %d recommendations from the server" % len(recs)
 
114
        if recs:
 
115
            self.recommendations = recs
 
116
            dt = REFRESH_INTERVAL
 
117
        else:
 
118
            dt = RETRY_INTERVAL
 
119
        GLib.timeout_add_seconds(dt, self.update_recommendations)
 
120
 
 
121
    def query_list_of_sources(self):
 
122
        """Query the server for a list of sources that will be used
 
123
        to build sources filter options and search queries."""
 
124
        msg = Soup.Message.new("GET", SERVER + "/sources")
 
125
        self.session.queue_message(msg, self._sources_cb, None)
 
126
 
 
127
    def _sources_cb(self, session, msg, user_data):
 
128
        if msg.status_code != 200:
 
129
            print "Error: Unable to query the server for a list of sources"
 
130
            print "       %d: %s" % (msg.status_code, msg.reason_phrase)
 
131
        else:
 
132
            try:
 
133
                sources_list = json.loads(msg.response_body.data)
 
134
            except ValueError:
 
135
                print "Error: got garbage json from the server"
 
136
            else:
 
137
                if isinstance(sources_list, dict):
 
138
                    sources_list = sources_list.get('results', [])
 
139
                if sources_list and sources_list != self.sources_list:
 
140
                    self.sources_list = sources_list
 
141
                    sources = self.scope.props.sources
 
142
                    for opt in sources.options[:]:
 
143
                        sources.remove_option(opt.props.id)
 
144
                    for source in self.sources_list:
 
145
                        sources.add_option(source, source, None)
 
146
        if self.sources_list:
 
147
            # refresh later
 
148
            dt = REFRESH_INTERVAL
 
149
        else:
 
150
            # retry soon
 
151
            dt = RETRY_INTERVAL
 
152
        GLib.timeout_add_seconds(dt, self.query_list_of_sources)
 
153
 
 
154
    def on_filtering_or_preference_changed(self, *_):
 
155
        """Run another search when a filter or preference change is notified."""
 
156
        self.scope.queue_search_changed(Unity.SearchType.DEFAULT)
 
157
 
 
158
    def refresh_online_videos(self, *_):
 
159
        """ Periodically refresh the results """
 
160
        self.scope.queue_search_changed(Unity.SearchType.DEFAULT)
 
161
        return True
 
162
 
 
163
    def source_activated(self, source):
 
164
        """Return the state of a sources filter option."""
 
165
        active = self.scope.props.sources.get_option(source).props.active
 
166
        filtering = self.scope.props.sources.props.filtering
 
167
        if (active and filtering) or (not active and not filtering):
 
168
            return True
 
169
        else:
 
170
            return False
 
171
            
 
172
    def at_least_one_source_is_on(self, sources):
 
173
        """Return a general activation state of all sources of this scope.
 
174
        This is needed, because we don't want to show recommends if an option
 
175
        from another scope is the only one activated"""
 
176
        filtering = self.scope.props.sources.props.filtering
 
177
        if filtering and len(sources) > 0 or not filtering:
 
178
            return True
 
179
        else:
 
180
            return False
 
181
 
 
182
    def on_search_changed(self, scope, search, search_type, _):
 
183
        """Get the search string, prepare a list of activated sources for 
 
184
        the server, update the model and notify the lens when we are done."""
 
185
        search_string = search.props.search_string.strip()
 
186
        # note search_string is a utf-8 encoded string, now:
 
187
        print "Remote scope search changed to %r" % search_string
 
188
        model = search.props.results_model
 
189
        # Clear results details map
 
190
        model.clear()
 
191
 
 
192
        # only perform the request if the user has not disabled
 
193
        # online/commercial suggestions. That will hide the category as well.
 
194
        if self.preferences.props.remote_content_search != Unity.PreferencesManagerRemoteContent.ALL:
 
195
            search.finished()
 
196
            return
 
197
 
 
198
        # Create a list of activated sources
 
199
        sources = [source for source in self.sources_list
 
200
                   if self.source_activated(source)]
 
201
        # If all the sources are activated, don't bother
 
202
        # passing them as arguments
 
203
        if len(sources) == len(self.sources_list):
 
204
            sources = ''
 
205
        else:
 
206
            sources = ','.join(sources)
 
207
        working = False
 
208
        # Ensure that we are not in Global search
 
209
        if search_type is Unity.SearchType.DEFAULT: 
 
210
            if self.at_least_one_source_is_on(sources):
 
211
                self.get_results(search_string, search, model, sources)
 
212
                working = True
 
213
        if search and not working:
 
214
            search.finished()
 
215
 
 
216
    def update_results_model(self, search, model, results, is_treat_yourself=False):
 
217
        """Run the search method and check if a list of sources is present.
 
218
        This is useful when coming out of a network issue."""
 
219
        if isinstance(results, dict):
 
220
            self.process_result_array(results['other'], model, CAT_INDEX_ONLINE)
 
221
            self.process_result_array(results['treats'], model, CAT_INDEX_MORE)
 
222
        else:
 
223
            category = CAT_INDEX_MORE if is_treat_yourself else CAT_INDEX_ONLINE
 
224
            self.process_result_array(results, model, category)
 
225
 
 
226
        if search:
 
227
            search.finished()
 
228
        if self.sources_list == []:
 
229
            self.query_list_of_sources()
 
230
 
 
231
    def process_result_array(self, results, model, category):
 
232
        """Run the search method and check if a list of sources is present.
 
233
        This is useful when coming out of a network issue."""
 
234
        for i in results:
 
235
            try:
 
236
                uri = i['url'].encode('utf-8')
 
237
                title = i['title'].encode('utf-8')
 
238
                icon = i['img'].encode('utf-8')
 
239
                result_icon = icon
 
240
                comment = i['source'].encode('utf-8')
 
241
                details_url = ""
 
242
                if "details" in i:
 
243
                    details_url = i['details'].encode('utf-8')
 
244
                if category == CAT_INDEX_MORE:
 
245
                    price = None
 
246
                    price_key = 'formatted_price'
 
247
                    if price_key in i:
 
248
                        price = i[price_key]
 
249
                    if not price or price == 'free':
 
250
                        price = _('Free')
 
251
                    anno_icon = Unity.AnnotatedIcon.new(Gio.FileIcon.new(Gio.File.new_for_uri(result_icon)))
 
252
                    anno_icon.props.category = Unity.CategoryType.MOVIE
 
253
                    anno_icon.props.ribbon = price
 
254
                    result_icon = anno_icon.to_string()
 
255
 
 
256
                if uri.startswith('http'):
 
257
                    # Instead of an uri, we pass a string of uri + metadata
 
258
                    fake_uri = "%slens-meta://%slens-meta://%slens-meta://%s"
 
259
                    fake_uri = fake_uri % (uri, title, icon, details_url)
 
260
                    model.append(fake_uri,
 
261
                        result_icon, category, "text/html",
 
262
                        str(title), str(comment), str(uri))
 
263
            except (KeyError, TypeError, AttributeError) as detail:
 
264
                print "Malformed result, skipping.", detail
 
265
 
 
266
    def get_results(self, search_string, search, model, sources):
 
267
        """Query the server with the search string and the list of sources."""
 
268
        if not search_string and not sources and self.recommendations:
 
269
            self.update_results_model(search, model, self.recommendations, True)
 
270
            return
 
271
        if self._pending is not None:
 
272
            self.session.cancel_message(self._pending,
 
273
                                        Soup.KnownStatusCode.CANCELLED)
 
274
        query = dict(q=search_string)
 
275
        query['split'] = 'true'
 
276
        if sources:
 
277
            query['sources'] = sources.encode("utf-8")
 
278
        query = urlencode(query)
 
279
        url = "%s/search?%s" % (SERVER, query)
 
280
        print "Querying the server:", url
 
281
        self._pending = Soup.Message.new("GET", url)
 
282
        self.session.queue_message(self._pending,
 
283
                                   self._search_cb,
 
284
                                   (search, model))
 
285
 
 
286
    def _search_cb(self, session, msg, (search, model)):
 
287
        if msg is not self._pending:
 
288
            # nothing to do here
 
289
            print "WAT? _search_cb snuck in on a non-pending msg"
 
290
            return
 
291
        self._pending = None
 
292
        results = self._handle_search_msg(msg)
 
293
        self.update_results_model(search, model, results)
 
294
 
 
295
    def _handle_search_msg(self, msg):
 
296
        results = []
 
297
        if msg.status_code != 200:
 
298
            print "Error: Unable to get results from the server"
 
299
            print "       %d: %s" % (msg.status_code, msg.reason_phrase)
 
300
        else:
 
301
            try:
 
302
                results = json.loads(msg.response_body.data)
 
303
            except ValueError:
 
304
                print "Error: got garbage json from the server"
 
305
        if isinstance(results, dict) and 'results' in results:
 
306
            results = results.get('results', [])
 
307
        return results
 
308
 
 
309
 
 
310
    def on_activate_uri (self, scope, rawuri):
 
311
        """On item clicked, parse the custom uri items"""
 
312
        # Parse the metadata before passing the whole to Zg
 
313
        uri = rawuri.split('lens-meta://')[0]
 
314
        title = rawuri.split('lens-meta://')[1]
 
315
        icon = rawuri.split('lens-meta://')[2]
 
316
        if ZGCLIENT:
 
317
            self.zeitgeist_insert_event(uri, title, icon)
 
318
        # Use direct glib activation instead of Unity's own method
 
319
        # to workaround an ActivationResponse bug.
 
320
        GLib.spawn_async(["/usr/bin/gvfs-open", uri])
 
321
        # Then close the Dash
 
322
        return Unity.ActivationResponse(goto_uri='', handled=2 )
 
323
 
 
324
    def on_preview_uri (self, scope, rawuri):
 
325
        """Preview request handler"""
 
326
        details_url = rawuri.split('lens-meta://')[3]
 
327
        details = self.get_details(details_url)
 
328
 
 
329
        title = rawuri.split('lens-meta://')[1]
 
330
        thumbnail_uri = rawuri.split('lens-meta://')[2]
 
331
        subtitle = ''
 
332
        desc = ''
 
333
        source = ''
 
334
        if isinstance(details, dict):
 
335
            title = details['title']
 
336
            thumbnail_uri = details['image']
 
337
            desc = details['description']
 
338
            source = details['source']
 
339
 
 
340
        try:
 
341
            thumbnail_file = Gio.File.new_for_uri (thumbnail_uri)
 
342
            thumbnail_icon = Gio.FileIcon.new(thumbnail_file)
 
343
        except:
 
344
            thumbnail_icon = None
 
345
        
 
346
        release_date = None
 
347
        duration = 0
 
348
            
 
349
        if "release_date" in details:
 
350
            try:
 
351
                # v1 spec states release_date will be YYYY-MM-DD
 
352
                release_date = datetime.strptime(details["release_date"], "%Y-%m-%d")
 
353
            except ValueError:
 
354
                print "Failed to parse release_date since it was not in format: YYYY-MM-DD"
 
355
 
 
356
        if "duration" in details:
 
357
            # Given in seconds, convert to minutes
 
358
            duration = int(details["duration"]) / 60
 
359
        
 
360
        if release_date != None:
 
361
            subtitle = release_date.strftime("%Y")
 
362
        if duration > 0:
 
363
            d = n_("%d min", "%d mins", duration) % duration
 
364
            if len(subtitle) > 0:
 
365
                subtitle += ", " + d
 
366
            else:
 
367
                subtitle = d
 
368
            
 
369
        preview = Unity.MoviePreview.new(title, subtitle, desc, thumbnail_icon)
 
370
        play_video = Unity.PreviewAction.new("play", _("Play"), None)
 
371
        play_video.connect('activated', self.play_video)
 
372
        preview.add_action(play_video)
 
373
        # For now, rating == -1 and num_ratings == 0 hides the rating widget from the preview
 
374
        preview.set_rating(-1, 0)
 
375
 
 
376
        # TODO: For details of future source types, factor out common detail key/value pairs
 
377
        if "directors" in details:
 
378
            directors = details["directors"]
 
379
            preview.add_info(Unity.InfoHint.new("directors",
 
380
                n_("Director", "Directors", len(directors)), None, ', '.join(directors)))
 
381
        if "starring" in details:
 
382
            cast = details["starring"]
 
383
            if cast:
 
384
                preview.add_info(Unity.InfoHint.new("cast",
 
385
                    n_("Cast", "Cast", len(cast)), None, ', '.join(cast)))
 
386
        if "genres" in details:
 
387
            genres = details["genres"] 
 
388
            if genres:
 
389
                preview.add_info(Unity.InfoHint.new("genres",
 
390
                    n_("Genre", "Genres", len(genres)), None, ', '.join(genres)))
 
391
        # TODO: Add Vimeo & YouTube details for v1 of JSON API
 
392
        if "uploaded_by" in details:
 
393
            preview.add_info(Unity.InfoHint.new("uploaded-by", _("Uploaded by"),
 
394
                None, details["uploaded_by"]))
 
395
        if "date_uploaded" in details:
 
396
            preview.add_info(Unity.InfoHint.new("uploaded-on", _("Uploaded on"),
 
397
                None, details["date_uploaded"]))
 
398
 
 
399
        return preview
 
400
 
 
401
    def get_details(self, details_url):
 
402
        """Get online video details for preview"""
 
403
        details = []
 
404
        if len(details_url) == 0:
 
405
            print "Cannot get preview details, no details_url specified."
 
406
            return details
 
407
 
 
408
        if self._pending is not None:
 
409
            self.session.cancel_message(self._pending,
 
410
                                        Soup.KnownStatusCode.CANCELLED)
 
411
 
 
412
        self._pending = Soup.Message.new("GET", details_url)
 
413
        # Send a synchronous GET request for video metadata details
 
414
        self.session.send_message(self._pending)
 
415
        try:
 
416
            details = json.loads(self._pending.response_body.data)
 
417
        except ValueError:
 
418
            print "Error: got garbage json from the server"
 
419
        except TypeError:
 
420
            print "Error: failed to connect to videosearch server to retrieve details."
 
421
 
 
422
        return details
 
423
 
 
424
    def play_video (self, action, uri):
 
425
        """Preview request - Play action handler"""
 
426
        return self.on_activate_uri (action, uri)
 
427
 
 
428
    def zeitgeist_insert_event(self, uri, title, icon):
 
429
        """Insert item uri as a new Zeitgeist event"""
 
430
        # "storage" is an optional zg string 
 
431
        # we use is to host the icon link. 
 
432
        # Storing it another way and referring to it lens side
 
433
        # would be more elegant but much more expensive.
 
434
        subject = Subject.new_for_values(
 
435
            uri = uri,
 
436
            interpretation=unicode(Interpretation.VIDEO),
 
437
            manifestation=unicode(Manifestation.FILE_DATA_OBJECT.REMOTE_DATA_OBJECT),
 
438
            origin=uri,
 
439
            storage=icon,
 
440
            text = title)
 
441
        event = Event.new_for_values(
 
442
            timestamp=int(time.time()*1000),
 
443
            interpretation=unicode(Interpretation.ACCESS_EVENT),
 
444
            manifestation=unicode(Manifestation.USER_ACTIVITY),
 
445
            actor="lens://unity-lens-video",
 
446
            subjects=[subject,])
 
447
        ZGCLIENT.insert_event(event)
 
448
 
 
449
def main():
 
450
    """Connect to the session bus, exit if there is a running instance."""
 
451
    try:
 
452
        session_bus_connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
 
453
        session_bus = Gio.DBusProxy.new_sync(session_bus_connection, 0, None,
 
454
                                                'org.freedesktop.DBus',
 
455
                                                '/org/freedesktop/DBus',
 
456
                                                'org.freedesktop.DBus', None)
 
457
        result = session_bus.call_sync('RequestName',
 
458
                                        GLib.Variant("(su)", (BUS_NAME, 0x4)),
 
459
                                        0, -1, None)
 
460
 
 
461
        # Unpack variant response with signature "(u)". 1 means we got it.
 
462
        result = result.unpack()[0]
 
463
 
 
464
        if result != 1:
 
465
            print >> sys.stderr, "Failed to own name %s. Bailing out." % BUS_NAME
 
466
            raise SystemExit(1)
 
467
    except:
 
468
        raise SystemExit(1)
 
469
 
 
470
    Daemon()
 
471
    GObject.MainLoop().run()
 
472
 
 
473
if __name__ == "__main__":
 
474
    main()