2
# -*- coding: utf-8 -*-
4
# Copyright (c) 2011 David Calle <davidc@framli.eu>
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.
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.
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/>.
19
"""The unity remote videos scope."""
23
from urllib import urlencode
25
from datetime import datetime
28
#pylint: disable=E0611
29
from zeitgeist.client import ZeitgeistClient
30
from zeitgeist.datamodel import (
37
from gi.repository import (
47
APP_NAME = "unity-lens-video"
48
LOCAL_PATH = "/usr/share/locale/"
50
gettext.bindtextdomain(APP_NAME, LOCAL_PATH)
51
gettext.textdomain(APP_NAME)
58
BUS_NAME = "net.launchpad.scope.RemoteVideos"
59
SERVER = "http://videosearch.ubuntu.com/v0"
61
REFRESH_INTERVAL = 3600 # fetch sources & recommendations once an hour
62
RETRY_INTERVAL = 60 # retry sources/recommendations after a minute
64
# Be cautious with Zeitgeist, don't assume it's ready
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")])
71
print "Unable to connect to Zeitgeist, won't send events."
76
"""Create the scope and listen to Unity signals"""
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)
99
# refresh the at least once every 30 minutes
100
GLib.timeout_add_seconds(REFRESH_INTERVAL/2, self.refresh_online_videos)
102
def update_recommendations(self):
103
"""Query the server for 'recommendations'.
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.
108
msg = Soup.Message.new("GET", SERVER + "/search?q=&sources=Amazon")
109
self.session.queue_message(msg, self._recs_cb, None)
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)
115
self.recommendations = recs
116
dt = REFRESH_INTERVAL
119
GLib.timeout_add_seconds(dt, self.update_recommendations)
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)
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)
133
sources_list = json.loads(msg.response_body.data)
135
print "Error: got garbage json from the server"
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:
148
dt = REFRESH_INTERVAL
152
GLib.timeout_add_seconds(dt, self.query_list_of_sources)
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)
158
def refresh_online_videos(self, *_):
159
""" Periodically refresh the results """
160
self.scope.queue_search_changed(Unity.SearchType.DEFAULT)
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):
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:
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
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:
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):
206
sources = ','.join(sources)
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)
213
if search and not working:
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)
223
category = CAT_INDEX_MORE if is_treat_yourself else CAT_INDEX_ONLINE
224
self.process_result_array(results, model, category)
228
if self.sources_list == []:
229
self.query_list_of_sources()
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."""
236
uri = i['url'].encode('utf-8')
237
title = i['title'].encode('utf-8')
238
icon = i['img'].encode('utf-8')
240
comment = i['source'].encode('utf-8')
243
details_url = i['details'].encode('utf-8')
244
if category == CAT_INDEX_MORE:
246
price_key = 'formatted_price'
249
if not price or 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()
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
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)
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'
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,
286
def _search_cb(self, session, msg, (search, model)):
287
if msg is not self._pending:
289
print "WAT? _search_cb snuck in on a non-pending msg"
292
results = self._handle_search_msg(msg)
293
self.update_results_model(search, model, results)
295
def _handle_search_msg(self, msg):
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)
302
results = json.loads(msg.response_body.data)
304
print "Error: got garbage json from the server"
305
if isinstance(results, dict) and 'results' in results:
306
results = results.get('results', [])
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]
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 )
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)
329
title = rawuri.split('lens-meta://')[1]
330
thumbnail_uri = rawuri.split('lens-meta://')[2]
334
if isinstance(details, dict):
335
title = details['title']
336
thumbnail_uri = details['image']
337
desc = details['description']
338
source = details['source']
341
thumbnail_file = Gio.File.new_for_uri (thumbnail_uri)
342
thumbnail_icon = Gio.FileIcon.new(thumbnail_file)
344
thumbnail_icon = None
349
if "release_date" in details:
351
# v1 spec states release_date will be YYYY-MM-DD
352
release_date = datetime.strptime(details["release_date"], "%Y-%m-%d")
354
print "Failed to parse release_date since it was not in format: YYYY-MM-DD"
356
if "duration" in details:
357
# Given in seconds, convert to minutes
358
duration = int(details["duration"]) / 60
360
if release_date != None:
361
subtitle = release_date.strftime("%Y")
363
d = n_("%d min", "%d mins", duration) % duration
364
if len(subtitle) > 0:
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)
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"]
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"]
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"]))
401
def get_details(self, details_url):
402
"""Get online video details for preview"""
404
if len(details_url) == 0:
405
print "Cannot get preview details, no details_url specified."
408
if self._pending is not None:
409
self.session.cancel_message(self._pending,
410
Soup.KnownStatusCode.CANCELLED)
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)
416
details = json.loads(self._pending.response_body.data)
418
print "Error: got garbage json from the server"
420
print "Error: failed to connect to videosearch server to retrieve details."
424
def play_video (self, action, uri):
425
"""Preview request - Play action handler"""
426
return self.on_activate_uri (action, uri)
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(
436
interpretation=unicode(Interpretation.VIDEO),
437
manifestation=unicode(Manifestation.FILE_DATA_OBJECT.REMOTE_DATA_OBJECT),
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",
447
ZGCLIENT.insert_event(event)
450
"""Connect to the session bus, exit if there is a running instance."""
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)),
461
# Unpack variant response with signature "(u)". 1 means we got it.
462
result = result.unpack()[0]
465
print >> sys.stderr, "Failed to own name %s. Bailing out." % BUS_NAME
471
GObject.MainLoop().run()
473
if __name__ == "__main__":