# -*- Mode: python; coding: utf-8; tab-width: 8; indent-tabs-mode: t; -*-
#
# Copyright (C) 2014 Jonathan Matthew
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2, or (at your option)
# any later version.
#
# The Rhythmbox authors hereby grant permission for non-GPL compatible
# GStreamer plugins to be used and distributed together with GStreamer
# and Rhythmbox. This permission is above and beyond the permissions granted
# by the GPL license by which Rhythmbox is covered. If you modify this code
# you may extend this exception to your version of the code, but you are not
# obligated to do so. If you do not wish to do so, delete this exception
# statement from your version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
from gi.repository import Gtk, Gdk, GObject, Gio, GLib, Peas
from gi.repository import RB
import rb
import urllib.parse
import json
from datetime import datetime
import gettext
gettext.install('rhythmbox', RB.locale_dir())
# rhythmbox app registered with soundcloud with the account notverysmart@gmail.com
CLIENT_ID = 'e4ef6572c2baf401db2f64b4e0eae9ce'
class SoundCloudEntryType(RB.RhythmDBEntryType):
def __init__(self):
RB.RhythmDBEntryType.__init__(self, name='soundcloud')
def do_get_playback_uri(self, entry):
uri = entry.get_string(RB.RhythmDBPropType.MOUNTPOINT)
return uri + "?client_id=" + CLIENT_ID
def do_can_sync_metadata(self, entry):
return False
class SoundCloudPlugin(GObject.Object, Peas.Activatable):
__gtype_name = 'SoundCloudPlugin'
object = GObject.property(type=GObject.GObject)
def __init__(self):
GObject.Object.__init__(self)
def do_activate(self):
shell = self.object
rb.append_plugin_source_path(self, "icons")
db = shell.props.db
self.entry_type = SoundCloudEntryType()
db.register_entry_type(self.entry_type)
model = RB.RhythmDBQueryModel.new_empty(db)
self.source = GObject.new (SoundCloudSource,
shell=shell,
name=_("SoundCloud"),
plugin=self,
query_model=model,
entry_type=self.entry_type,
icon=Gio.ThemedIcon.new("soundcloud-symbolic"))
shell.register_entry_type_for_source(self.source, self.entry_type)
self.source.setup()
group = RB.DisplayPageGroup.get_by_id ("shared")
shell.append_display_page(self.source, group)
def do_deactivate(self):
self.source.delete_thyself()
self.source = None
class SoundCloudSource(RB.StreamingSource):
def __init__(self, **kwargs):
super(SoundCloudSource, self).__init__(kwargs)
self.loader = None
self.container_loader = None
self.container_marker_path = None
self.search_count = 1
self.search_types = {
'tracks': {
'label': _("Search tracks"),
'placeholder': _("Search tracks on SoundCloud"),
'title': "", # container view is hidden
'endpoint': '/tracks.json',
'containers': False
},
'sets': {
'label': _("Search sets"),
'placeholder': _("Search sets on SoundCloud"),
'title': _("SoundCloud Sets"),
'endpoint': '/playlists.json',
'containers': True
},
'users': {
'label': _("Search users"),
'placeholder': _("Search users on SoundCloud"),
'title': _("SoundCloud Users"),
'endpoint': '/users.json',
'containers': True
},
'groups': {
'label': _("Search groups"),
'placeholder': _("Search groups on SoundCloud"),
'title': _("SoundCloud Groups"),
'endpoint': '/groups.json',
'containers': True
},
}
self.container_types = {
'user': {
'attributes': ['username', 'kind', 'uri', 'permalink_url', 'avatar_url', 'description'],
'tracks-url': '/tracks.json',
'tracks-type': 'plain',
},
'playlist': {
'attributes': ['title', 'kind', 'uri', 'permalink_url', 'artwork_url', 'description'],
'tracks-url': '.json',
'tracks-type': 'playlist',
},
'group': {
'attributes': ['name', 'kind', 'uri', 'permalink_url', 'artwork_url', 'description'],
'tracks-url': '/tracks.json',
'tracks-type': 'plain',
}
}
def hide_entry_cb(self, entry):
shell = self.props.shell
shell.props.db.entry_set(entry, RB.RhythmDBPropType.HIDDEN, True)
def new_model(self):
shell = self.props.shell
plugin = self.props.plugin
db = shell.props.db
self.search_count = self.search_count + 1
q = GLib.PtrArray()
db.query_append_params(q, RB.RhythmDBQueryType.EQUALS, RB.RhythmDBPropType.TYPE, plugin.entry_type)
db.query_append_params(q, RB.RhythmDBQueryType.EQUALS, RB.RhythmDBPropType.LAST_SEEN, self.search_count)
model = RB.RhythmDBQueryModel.new_empty(db)
db.do_full_query_async_parsed(model, q)
self.props.query_model = model
self.songs.set_model(model)
def add_track(self, db, entry_type, item):
if not item['streamable']:
return
uri = item['permalink_url']
entry = db.entry_lookup_by_location(uri)
if entry:
db.entry_set(entry, RB.RhythmDBPropType.LAST_SEEN, self.search_count)
else:
entry = RB.RhythmDBEntry.new(db, entry_type, item['permalink_url'])
db.entry_set(entry, RB.RhythmDBPropType.MOUNTPOINT, item['stream_url'])
db.entry_set(entry, RB.RhythmDBPropType.ARTIST, item['user']['username'])
db.entry_set(entry, RB.RhythmDBPropType.TITLE, item['title'])
db.entry_set(entry, RB.RhythmDBPropType.LAST_SEEN, self.search_count)
if item['genre'] is not None:
db.entry_set(entry, RB.RhythmDBPropType.GENRE, item['genre'])
db.entry_set(entry, RB.RhythmDBPropType.DURATION, item['duration']/1000)
if item['bpm'] is not None:
db.entry_set(entry, RB.RhythmDBPropType.BEATS_PER_MINUTE, item['bpm'])
if item['description'] is not None:
db.entry_set(entry, RB.RhythmDBPropType.COMMENT, item['description'])
if item['artwork_url'] is not None:
db.entry_set(entry, RB.RhythmDBPropType.MB_ALBUMID, item['artwork_url'])
if item['release_year'] is not None:
date = GLib.Date.new_dmy(item.get('release_day', 0), item.get('release_month', 0), item['release_year'])
db.entry_set(entry, RB.RhythmDBPropType.DATE, date.get_julian())
if item['created_at'] is not None:
try:
dt = datetime.strptime(item['created_at'], '%Y/%m/%d %H:%M:%S %z')
db.entry_set(entry, RB.RhythmDBPropType.FIRST_SEEN, int(dt.timestamp()))
except Exception as e:
print(str(e))
db.commit()
def add_container(self, item):
k = item['kind']
if k not in self.container_types:
return
ct = self.container_types[k]
self.containers.append([item.get(i) for i in ct['attributes']])
def add_container_marker(self):
iter = self.containers.append(['...', None, None, None, None, None])
self.container_marker_path = self.containers.get_path(iter)
def remove_container_marker(self):
if self.container_marker_path is None:
return
iter = self.containers.get_iter(self.container_marker_path)
self.containers.remove(iter)
self.container_marker_path = None
def search_tracks_api_cb(self, data):
if data is None:
return
shell = self.props.shell
db = shell.props.db
entry_type = self.props.entry_type
data = data.decode('utf-8')
stuff = json.loads(data)
for item in stuff['collection']:
self.add_track(db, entry_type, item)
if 'next_href' in stuff:
self.more_tracks_url = stuff.get('next_href')
self.fetch_more_button.set_sensitive(True)
def search_containers_api_cb(self, data):
if data is None:
return
self.remove_container_marker()
self.container_loader = None
entry_type = self.props.entry_type
data = data.decode('utf-8')
stuff = json.loads(data)
for item in stuff['collection']:
self.add_container(item)
if 'next_href' in stuff:
self.add_container_marker()
self.more_containers_url = stuff.get('next_href')
def resolve_api_cb(self, data):
if data is None:
return
data = data.decode('utf-8')
stuff = json.loads(data)
if stuff['kind'] == 'track':
shell = self.props.shell
db = shell.props.db
self.add_track(db, self.props.entry_type, stuff)
else:
self.add_container(stuff)
# select, etc. too?
def playlist_api_cb(self, data):
if data is None:
return
shell = self.props.shell
db = shell.props.db
data = data.decode('utf-8')
stuff = json.loads(data)
for t in stuff['tracks']:
self.add_track(db, self.props.entry_type, t)
def cancel_request(self, cancel_containers=False):
if self.loader:
self.loader.cancel()
self.loader = None
if self.container_loader and cancel_containers:
self.container_loader.cancel()
self.container_loader = None
def search_popup_cb(self, widget):
self.search_popup.popup(None, None, None, None, 3, Gtk.get_current_event_time())
def search_type_action_cb(self, action, parameter):
print(parameter.get_string() + " selected")
self.search_type = parameter.get_string()
if self.search_entry.searching():
self.do_search()
st = self.search_types[self.search_type]
self.search_entry.set_placeholder(st['placeholder'])
def search_entry_cb(self, widget, term):
self.search_text = term
self.do_search()
def show_more_cb(self, button):
button.set_sensitive(False)
if self.more_tracks_url:
self.cancel_request(False)
print("fetching more tracks")
self.loader = rb.Loader()
self.loader.get_url(self.more_tracks_url, self.search_tracks_api_cb)
def do_search(self):
self.cancel_request()
base = 'https://api.soundcloud.com'
self.new_model()
self.containers.clear()
self.more_tracks_url = None
self.more_containers_url = None
self.fetch_more_button.set_sensitive(False)
term = self.search_text
if term.startswith('https://soundcloud.com/') or term.startswith("http://soundcloud.com/"):
# ignore the selected search type and try to resolve whatever the url is
print("resolving " + term)
self.scrolled.hide()
url = base + '/resolve.json?url=' + term + '&client_id=' + CLIENT_ID
self.loader = rb.Loader()
self.loader.get_url(url, self.resolve_api_cb)
return
if self.search_type not in self.search_types:
print("not sure how to search for " + self.search_type)
return
print("searching for " + self.search_type + " matching " + term)
st = self.search_types[self.search_type]
self.container_view.get_column(0).set_title(st['title'])
url = base + st['endpoint'] + '?q=' + urllib.parse.quote(term) + '&linked_partitioning=1&limit=100&client_id=' + CLIENT_ID
self.loader = rb.Loader()
if st['containers']:
self.scrolled.show()
self.loader.get_url(url, self.search_containers_api_cb)
else:
self.scrolled.hide()
self.loader.get_url(url, self.search_tracks_api_cb)
def selection_changed_cb(self, selection):
self.new_model()
self.cancel_request()
self.build_sc_menu()
(model, aiter) = selection.get_selected()
if aiter is None:
return
if self.container_marker_path is not None:
apath = self.containers.get_path(aiter)
if apath.compare(self.container_marker_path) == 0:
print("marker row selected")
return
[itemtype, url] = model.get(aiter, 1, 2)
if itemtype not in self.container_types:
return
print("loading %s %s" % (itemtype, url))
ct = self.container_types[itemtype]
trackurl = url + ct['tracks-url'] + '?linked_partitioning=1&client_id=' + CLIENT_ID
self.loader = rb.Loader()
if ct['tracks-type'] == 'playlist':
self.loader.get_url(trackurl, self.playlist_api_cb)
else:
self.loader.get_url(trackurl, self.search_tracks_api_cb)
def maybe_more_containers(self):
self.more_containers_idle = 0
if self.container_loader is not None:
return False
(start, end) = self.container_view.get_visible_range()
if self.container_marker_path.compare(end) == 1:
return False
self.container_loader = rb.Loader()
self.container_loader.get_url(self.more_containers_url, self.search_containers_api_cb)
return False
def scroll_adjust_changed_cb(self, adjust):
if self.more_containers_url is None:
return
if self.more_containers_idle == 0:
self.more_containers_idle = GLib.idle_add(self.maybe_more_containers)
def sort_order_changed_cb(self, obj, pspec):
obj.resort_model()
def songs_selection_changed_cb(self, songs):
self.build_sc_menu()
def playing_entry_changed_cb(self, player, entry):
self.build_sc_menu()
if not entry:
return
if entry.get_entry_type() != self.props.entry_type:
return
au = entry.get_string(RB.RhythmDBPropType.MB_ALBUMID)
if au:
key = RB.ExtDBKey.create_storage("title", entry.get_string(RB.RhythmDBPropType.TITLE))
key.add_field("artist", entry.get_string(RB.RhythmDBPropType.ARTIST))
self.art_store.store_uri(key, RB.ExtDBSourceType.EMBEDDED, au)
def open_uri_action_cb(self, action, param):
shell = self.props.shell
window = shell.props.window
screen = window.get_screen()
uri = param.get_string()
Gtk.show_uri(screen, uri, Gdk.CURRENT_TIME)
def build_sc_menu(self):
menu = {}
# playing track
shell = self.props.shell
player = shell.props.shell_player
entry = player.get_playing_entry()
if entry is not None and entry.get_entry_type() == self.props.entry_type:
url = entry.get_string(RB.RhythmDBPropType.LOCATION)
menu[url] = _("View '%(title)s' on SoundCloud") % {'title': entry.get_string(RB.RhythmDBPropType.TITLE) }
# artist too?
# selected track
if self.songs.have_selection():
entry = self.songs.get_selected_entries()[0]
url = entry.get_string(RB.RhythmDBPropType.LOCATION)
menu[url] = _("View '%(title)s' on SoundCloud") % {'title': entry.get_string(RB.RhythmDBPropType.TITLE) }
# artist too?
# selected container
selection = self.container_view.get_selection()
(model, aiter) = selection.get_selected()
if aiter is not None:
[name, url] = model.get(aiter, 0, 3)
menu[url] = _("View '%(container)s' on SoundCloud") % {'container': name}
if len(menu) == 0:
self.sc_button.set_menu_model(None)
self.sc_button.set_sensitive(False)
return None
m = Gio.Menu()
for u in menu:
i = Gio.MenuItem()
i.set_label(menu[u])
i.set_action_and_target_value("win.soundcloud-open-uri", GLib.Variant.new_string(u))
m.append_item(i)
self.sc_button.set_menu_model(m)
self.sc_button.set_sensitive(True)
def setup(self):
shell = self.props.shell
builder = Gtk.Builder()
builder.add_from_file(rb.find_plugin_file(self.props.plugin, "soundcloud.ui"))
self.scrolled = builder.get_object("container-scrolled")
self.scrolled.set_no_show_all(True)
self.scrolled.hide()
self.more_containers_idle = 0
adj = self.scrolled.get_vadjustment()
adj.connect("changed", self.scroll_adjust_changed_cb)
adj.connect("value-changed", self.scroll_adjust_changed_cb)
self.search_entry = RB.SearchEntry(spacing=6)
self.search_entry.props.explicit_mode = True
self.fetch_more_button = Gtk.Button.new_with_label(_("Fetch more tracks"))
self.fetch_more_button.connect("clicked", self.show_more_cb)
action = Gio.SimpleAction.new("soundcloud-search-type", GLib.VariantType.new('s'))
action.connect("activate", self.search_type_action_cb)
shell.props.window.add_action(action)
m = Gio.Menu()
for st in sorted(self.search_types):
i = Gio.MenuItem()
i.set_label(self.search_types[st]['label'])
i.set_action_and_target_value("win.soundcloud-search-type", GLib.Variant.new_string(st))
m.append_item(i)
self.search_popup = Gtk.Menu.new_from_model(m)
action.activate(GLib.Variant.new_string("tracks"))
grid = builder.get_object("soundcloud-source")
self.search_entry.connect("search", self.search_entry_cb)
self.search_entry.connect("activate", self.search_entry_cb)
self.search_entry.connect("show-popup", self.search_popup_cb)
self.search_entry.set_size_request(400, -1)
searchbox = builder.get_object("search-box")
searchbox.pack_start(self.search_entry, False, True, 0)
searchbox.pack_start(self.fetch_more_button, False, True, 0)
self.search_popup.attach_to_widget(self.search_entry, None)
self.containers = builder.get_object("container-store")
self.container_view = builder.get_object("containers")
self.container_view.set_model(self.containers)
action = Gio.SimpleAction.new("soundcloud-open-uri", GLib.VariantType.new('s'))
action.connect("activate", self.open_uri_action_cb)
shell.props.window.add_action(action)
r = Gtk.CellRendererText()
c = Gtk.TreeViewColumn("", r, text=0)
self.container_view.append_column(c)
self.container_view.get_selection().connect('changed', self.selection_changed_cb)
self.songs = RB.EntryView(db=shell.props.db,
shell_player=shell.props.shell_player,
is_drag_source=True,
is_drag_dest=False)
self.songs.append_column(RB.EntryViewColumn.TITLE, True)
self.songs.append_column(RB.EntryViewColumn.ARTIST, True)
self.songs.append_column(RB.EntryViewColumn.DURATION, True)
self.songs.append_column(RB.EntryViewColumn.YEAR, False)
self.songs.append_column(RB.EntryViewColumn.GENRE, False)
self.songs.append_column(RB.EntryViewColumn.BPM, False)
self.songs.append_column(RB.EntryViewColumn.FIRST_SEEN, False)
self.songs.set_model(self.props.query_model)
self.songs.connect("notify::sort-order", self.sort_order_changed_cb)
self.songs.connect("selection-changed", self.songs_selection_changed_cb)
paned = builder.get_object("paned")
paned.pack2(self.songs)
self.bind_settings(self.songs, paned, None, True)
self.sc_button = Gtk.MenuButton()
self.sc_button.set_relief(Gtk.ReliefStyle.NONE)
img = Gtk.Image.new_from_file(rb.find_plugin_file(self.props.plugin, "powered-by-soundcloud.png"))
self.sc_button.add(img)
box = builder.get_object("soundcloud-button-box")
box.pack_start(self.sc_button, True, True, 0)
self.build_sc_menu()
self.pack_start(grid, expand=True, fill=True, padding=0)
grid.show_all()
self.art_store = RB.ExtDB(name="album-art")
player = shell.props.shell_player
player.connect('playing-song-changed', self.playing_entry_changed_cb)
def do_get_entry_view(self):
return self.songs
def do_get_playback_status(self, text, progress):
return self.get_progress()
def do_can_copy(self):
return False
def do_can_pause(self):
return True
GObject.type_register(SoundCloudSource)