[gnome-music] Initial smart playlists implementation
- From: Vadim Rutkovsky <vrutkovsky src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [gnome-music] Initial smart playlists implementation
- Date: Mon, 19 Jan 2015 11:05:34 +0000 (UTC)
commit a711301d308970c2bbf0863c8f722d5d1b107e85
Author: Maia <maia mcc gmail com>
Date: Mon Jan 5 13:39:49 2015 -0500
Initial smart playlists implementation
https://bugzilla.gnome.org/show_bug.cgi?id=702519
data/org.gnome.Music.gschema.xml | 5 +
gnomemusic/grilo.py | 2 +-
gnomemusic/player.py | 14 +++
gnomemusic/playlists.py | 124 ++++++++++++++++++++---
gnomemusic/query.py | 205 ++++++++++++++++++++++++++++++++++++++
gnomemusic/view.py | 23 ++++-
gnomemusic/window.py | 2 +-
7 files changed, 358 insertions(+), 17 deletions(-)
---
diff --git a/data/org.gnome.Music.gschema.xml b/data/org.gnome.Music.gschema.xml
index 0bc8b29..d50112f 100644
--- a/data/org.gnome.Music.gschema.xml
+++ b/data/org.gnome.Music.gschema.xml
@@ -48,5 +48,10 @@
<summary>Enable ReplayGain</summary>
<description>Enables or disables ReplayGain for albums</description>
</key>
+ <key type="i" name="most-played-playlist-id">
+ <default>0</default>
+ <summary>Internal: id for most played playlist</summary>
+ <description></description>
+ </key>
</schema>
</schemalist>
diff --git a/gnomemusic/grilo.py b/gnomemusic/grilo.py
index be2bd86..82c2318 100644
--- a/gnomemusic/grilo.py
+++ b/gnomemusic/grilo.py
@@ -266,4 +266,4 @@ class Grilo(GObject.GObject):
Grl.init(None)
-grilo = Grilo()
+grilo = Grilo()
\ No newline at end of file
diff --git a/gnomemusic/player.py b/gnomemusic/player.py
index 3ff1f68..ad28e84 100644
--- a/gnomemusic/player.py
+++ b/gnomemusic/player.py
@@ -39,6 +39,8 @@ from gettext import gettext as _
from random import randint
from queue import LifoQueue
from gnomemusic.albumArtCache import AlbumArtCache
+from gnomemusic.playlists import Playlists
+playlists = Playlists.get_default()
from gnomemusic import log
import logging
@@ -667,6 +669,8 @@ class Player(GObject.GObject):
@log
def _set_duration(self, duration):
self.duration = duration
+ self.played_seconds = 0
+ self.scrobbled = False
self.progressScale.set_range(0.0, duration * 60)
@log
@@ -674,6 +678,16 @@ class Player(GObject.GObject):
position = self.player.query_position(Gst.Format.TIME)[1] / 1000000000
if position > 0:
self.progressScale.set_value(position * 60)
+ self.played_seconds += 1
+ try:
+ percentage = self.played_seconds / self.duration
+ if not self.scrobbled and percentage > 0.4:
+ just_played_url = self.get_current_media().get_url()
+ self.scrobbled = True
+ playlists.update_playcount(just_played_url)
+ playlists.update_last_played(just_played_url)
+ except Exception as e:
+ logger.warn("Error: %s, %s" % (e.__class__, e))
return True
@log
diff --git a/gnomemusic/playlists.py b/gnomemusic/playlists.py
index 7907bd6..a5cdb47 100644
--- a/gnomemusic/playlists.py
+++ b/gnomemusic/playlists.py
@@ -26,20 +26,41 @@
# delete this exception statement from your version.
-from gi.repository import Grl, GLib, GObject
-from gi.repository import Tracker
+from gi.repository import Grl, GLib, GObject, Gio, Tracker
from gnomemusic.grilo import grilo
from gnomemusic.query import Query
+import inspect
+import time
+sparql_dateTime_format = "%Y-%m-%dT%H:%M:%SZ"
from gnomemusic import log
import logging
logger = logging.getLogger(__name__)
+class StaticPlaylists:
+ class MostPlayed:
+ ID = None
+ QUERY = Query.get_most_played_songs()
+ TAG_TEXT = "MOST_PLAYED"
+ TITLE = "Most Played" # Will eventually be translated
+ class NeverPlayed:
+ ID = None
+ QUERY = Query.get_never_played_songs()
+ TAG_TEXT = "NEVER_PLAYED"
+ TITLE = "Never Played" # Will eventually be translated
+ class RecentlyPlayed:
+ ID = None
+ QUERY = Query.get_recently_played_songs()
+ TAG_TEXT = "RECENTLY_PLAYED"
+ TITLE = "Recently Played" # Will eventually be translated
+
+
class Playlists(GObject.GObject):
__gsignals__ = {
'playlist-created': (GObject.SIGNAL_RUN_FIRST, None, (Grl.Media,)),
'playlist-deleted': (GObject.SIGNAL_RUN_FIRST, None, (Grl.Media,)),
+ 'playlist-updated': (GObject.SIGNAL_RUN_FIRST, None, (int,)),
'song-added-to-playlist': (
GObject.SIGNAL_RUN_FIRST, None, (Grl.Media, Grl.Media)
),
@@ -48,27 +69,104 @@ class Playlists(GObject.GObject):
),
}
instance = None
+ tracker = None
@classmethod
- def get_default(self):
+ def get_default(self, tracker=None):
if self.instance:
+ if not self.tracker and tracker:
+ self.instance.tracker = tracker
return self.instance
else:
- self.instance = Playlists()
+ self.instance = Playlists(tracker)
return self.instance
@log
- def __init__(self):
+ def __init__(self, tracker):
GObject.GObject.__init__(self)
- try:
- self.tracker = Tracker.SparqlConnection.get(None)
- except Exception as e:
- from sys import exit
- logger.error("Cannot connect to tracker, error '%s'\Exiting" % str(e))
- exit(1)
+ self.tracker = tracker
+
+ @log
+ def fetch_or_create_static_playlists(self):
+ """For all static playlists: get ID, if exists; if not, create the playlist and get ID."""
+ for playlist in [cls for name, cls in inspect.getmembers(StaticPlaylists) if inspect.isclass(cls) \
+ and not (name == "__class__")]: # hacky
+ cursor = self.tracker.query(Query.get_playlist_with_tag(playlist.TAG_TEXT), None)
+ while cursor.next():
+ playlist_id = cursor.get_string(1)[0]
+ playlist.ID = int(playlist_id) # hacky; shouldn't be reassigned every time
+
+ if not playlist.ID:
+ # create the playlist
+ playlist.ID = self.create_playlist_and_return_id(playlist.TITLE, playlist.TAG_TEXT)
+
+ # then update all smart playlists
+ self.update_most_played_playlist()
+
+ @log
+ def clear_playlist_with_id(self, playlist_id):
+ query = Query.clear_playlist_with_id(playlist_id)
+ self.tracker.update(query, GLib.PRIORITY_DEFAULT, None)
+
+ @log
+ def update_playcount(self, song_url):
+ query = Query.update_playcount(song_url)
+ self.tracker.update(query, GLib.PRIORITY_DEFAULT, None)
+ self.update_all_static_playlists() # not the best place to put this func;
+ # maybe a 'scrobble' func that updates playcount & last played and then updates playlists?
+
+ @log
+ def update_last_played(self, song_url):
+ cur_time = time.strftime(sparql_dateTime_format, time.gmtime())
+ query = Query.update_last_played(song_url, cur_time)
+ self.tracker.update(query, GLib.PRIORITY_DEFAULT, None)
+
+ def update_static_playlist(self, playlist):
+ """Given a static playlist (subclass of StaticPlaylists), updates according to its query."""
+ # Clear the playlist
+ self.clear_playlist_with_id(playlist.ID)
+
+ # Get a list of matching songs
+ cursor = self.tracker.query(playlist.QUERY, None)
+ if not cursor:
+ return
+
+ # For each song run 'add song to playlist'
+ while cursor.next():
+ uri = cursor.get_string(0)[0]
+ self.tracker.update_blank_async(
+ Query.add_song_to_playlist(playlist.ID, uri),
+ GLib.PRIORITY_DEFAULT,
+ None, None, None
+ )
+
+ # tell system we updated the playlist so playlist is reloaded
+ self.emit('playlist-updated', playlist.ID)
+
+ def update_all_static_playlists(self):
+ for playlist in [cls for name, cls in inspect.getmembers(StaticPlaylists) if inspect.isclass(cls) \
+ and not (name == "__class__")]: # hacky
+ self.update_static_playlist(playlist)
+
+ @log
+ def create_playlist_and_return_id(self, title, tag_text):
+ self.tracker.update_blank(Query.create_tag(tag_text), GLib.PRIORITY_DEFAULT, None)
+
+ data = self.tracker.update_blank(
+ Query.create_playlist_with_tag(title, tag_text), GLib.PRIORITY_DEFAULT,
+ None)
+ playlist_urn = data.get_child_value(0).get_child_value(0).\
+ get_child_value(0).get_child_value(1).get_string()
+
+ cursor = self.tracker.query(
+ Query.get_playlist_with_urn(playlist_urn),
+ None)
+ if not cursor or not cursor.next():
+ return
+ return cursor.get_integer(0)
@log
- def create_playlist(self, name):
+ def create_playlist(self, title):
def get_callback(source, param, item, count, data, error):
if item:
self.emit('playlist-created', item)
@@ -88,7 +186,7 @@ class Playlists(GObject.GObject):
)
self.tracker.update_blank_async(
- Query.create_playlist(name), GLib.PRIORITY_DEFAULT,
+ Query.create_playlist(title), GLib.PRIORITY_DEFAULT,
None, update_callback, None
)
diff --git a/gnomemusic/query.py b/gnomemusic/query.py
index dd7f4fa..16e5959 100644
--- a/gnomemusic/query.py
+++ b/gnomemusic/query.py
@@ -31,6 +31,10 @@ import os
import logging
logger = logging.getLogger(__name__)
+import time
+sparql_midnight_dateTime_format = "%Y-%m-%dT00:00:00Z"
+SECONDS_PER_DAY = 86400
+
class Query():
music_folder = None
@@ -651,6 +655,45 @@ class Query():
return query
@staticmethod
+ def update_playcount(song_url):
+ query = """
+ INSERT OR REPLACE { ?song nie:usageCounter ?playcount . }
+ WHERE {
+ SELECT
+ IF(bound(?usage), (?usage + 1), 1) AS playcount
+ ?song
+ WHERE {
+ ?song a nmm:MusicPiece .
+ OPTIONAL { ?song nie:usageCounter ?usage . }
+ FILTER ( nie:url(?song) = "%(song_url)s" )
+ }
+ }
+ """.replace("\n", " ").strip() % {
+ 'song_url': song_url
+ }
+
+ return query
+
+ @staticmethod
+ def update_last_played(song_url, time):
+ query = """
+ INSERT OR REPLACE { ?song nfo:fileLastAccessed '%(time)s' . }
+ WHERE {
+ SELECT
+ ?song
+ WHERE {
+ ?song a nmm:MusicPiece .
+ FILTER ( nie:url(?song) = "%(song_url)s" )
+ }
+ }
+ """.replace("\n", " ").strip() % {
+ 'song_url': song_url,
+ 'time': time
+ }
+
+ return query
+
+ @staticmethod
def create_playlist(title):
query = """
INSERT {
@@ -666,6 +709,45 @@ class Query():
return query
@staticmethod
+ def create_tag(tag_text):
+ query = """
+ INSERT OR REPLACE {
+ _:tag
+ a nao:Tag ;
+ rdfs:comment '%(tag_text)s'.
+ }
+ """.replace("\n", " ").strip() % {
+ 'tag_text': tag_text
+ }
+ return query
+
+ @staticmethod
+ def create_playlist_with_tag(title, tag_text):
+ # TODO: make this an extension of 'create playlist' rather than its own func.?
+ # TODO: CREATE TAG IF IT DOESN'T EXIST!
+ query = """
+ INSERT {
+ _:playlist
+ a nmm:Playlist ;
+ a nfo:MediaList ;
+ nie:title "%(title)s" ;
+ nfo:entryCounter 0 ;
+ nao:hasTag ?tag.
+ }
+ WHERE {
+ SELECT ?tag
+ WHERE {
+ ?tag a nao:Tag ;
+ rdfs:comment '%(tag_text)s'.
+ }
+ }
+ """.replace("\n", " ").strip() % {
+ 'title': title,
+ 'tag_text': tag_text
+ }
+ return query
+
+ @staticmethod
def delete_playlist(playlist_id):
query = """
DELETE {
@@ -812,6 +894,19 @@ class Query():
return Query.playlists(query)
@staticmethod
+ def get_playlist_with_tag(playlist_tag):
+ query = """
+ ?playlist
+ a nmm:Playlist ;
+ nao:hasTag ?tag .
+ ?tag rdfs:comment ?tag_text .
+ FILTER ( ?tag_text = '%(playlist_tag)s' )
+ """.replace('\n', ' ').strip() % {'playlist_tag': playlist_tag}
+
+ return Query.playlists(query)
+
+
+ @staticmethod
def get_playlist_with_urn(playlist_urn):
query = """
SELECT DISTINCT
@@ -839,6 +934,79 @@ class Query():
""".replace('\n', ' ').strip() % {'entry_urn': entry_urn}
return query
+ @staticmethod
+ def clear_playlist_with_id(playlist_id):
+ query = """
+ DELETE {
+ ?playlist
+ nfo:hasMediaFileListEntry ?entry .
+ ?entry
+ a rdfs:Resource .
+ }
+ WHERE {
+ ?playlist
+ a nmm:Playlist ;
+ a nfo:MediaList ;
+ nfo:hasMediaFileListEntry ?entry .
+ FILTER (
+ tracker:id(?playlist) = %(playlist_id)s
+ )
+ }
+ """.replace('\n', ' ').strip() % {'playlist_id': playlist_id}
+
+ return query
+
+ @staticmethod
+ def get_most_played_songs():
+ # TODO: set playlist size somewhere? Currently default is 5, this is probably too low...
+ query = """
+ SELECT ?url
+ WHERE {
+ ?song a nmm:MusicPiece ;
+ nie:usageCounter ?count ;
+ nie:isStoredAs ?as .
+ ?as nie:url ?url .
+ } ORDER BY DESC(?count) LIMIT 50
+ """
+
+ return query
+
+ @staticmethod
+ def get_never_played_songs():
+ query = """
+ SELECT ?url
+ WHERE {
+ ?song a nmm:MusicPiece ;
+ nie:isStoredAs ?as .
+ ?as nie:url ?url .
+ FILTER ( NOT EXISTS { ?song nie:usageCounter ?count .} )
+ } ORDER BY nfo:fileLastAccessed(?song)
+ """.replace('\n', ' ').strip()
+
+ return query
+
+ def get_recently_played_songs():
+ #TODO: or this could take comparison date as an argument so we don't need to make a date string in
query.py...
+ #TODO: set time interval somewhere? A settings file? (Default is maybe 2 weeks...?)
+
+ days_difference = 3 # currently hardcoding time interval of 3 days
+ seconds_difference = days_difference * SECONDS_PER_DAY
+ compare_date = time.strftime(sparql_midnight_dateTime_format,
time.gmtime(time.time()-seconds_difference))
+
+ query = """
+ SELECT ?url
+ WHERE {
+ ?song a nmm:MusicPiece ;
+ nie:isStoredAs ?as ;
+ nfo:fileLastAccessed ?last_played .
+ ?as nie:url ?url .
+ FILTER ( ?last_played > '%(compare_date)s'^^xsd:dateTime )
+ } ORDER BY DESC(?last_played)
+ """.replace('\n', ' ').strip() % {'compare_date': compare_date}
+
+ return query
+
+
# Functions for search
# TODO: make those queries actually return something
@staticmethod
@@ -1064,3 +1232,40 @@ class Query():
'''.replace('\n', ' ').strip() % {'name': name}
return Query.songs(query)
+
+ @staticmethod
+ def clear_playlist(playlist_id):
+ # TODO is there a way to do this with only one FILTER statement?
+
+ query = """
+ DELETE {
+ ?playlist
+ nfo:hasMediaFileListEntry ?entry .
+ ?entry
+ a rdfs:Resource .
+ }
+ WHERE {
+ ?playlist
+ a nmm:Playlist ;
+ a nfo:MediaList ;
+ nfo:hasMediaFileListEntry ?entry .
+ FILTER (
+ tracker:id(?playlist) = %(playlist_id)s
+ )
+ }
+ INSERT OR REPLACE {
+ ?playlist nfo:entryCounter '0'
+ }
+ WHERE {
+ ?playlist
+ a nmm:Playlist ;
+ a nfo:MediaList .
+ FILTER (
+ tracker:id(?playlist) = %(playlist_id)s
+ )
+ }
+ """.replace("\n", " ").strip() % {
+ 'playlist_id': playlist_id
+ }
+
+ return query
\ No newline at end of file
diff --git a/gnomemusic/view.py b/gnomemusic/view.py
index 6092c1d..c3625ae 100644
--- a/gnomemusic/view.py
+++ b/gnomemusic/view.py
@@ -803,6 +803,7 @@ class Playlist(ViewContainer):
self.player = player
self.player.connect('playlist-item-changed', self.update_model)
playlists.connect('playlist-created', self._on_playlist_created)
+ playlists.connect('playlist-updated', self.on_playlist_update)
playlists.connect('song-added-to-playlist', self._on_song_added_to_playlist)
playlists.connect('song-removed-from-playlist', self._on_song_removed_from_playlist)
self.show_all()
@@ -958,7 +959,21 @@ class Playlist(ViewContainer):
self.player.set_playing(True)
@log
+ def on_playlist_update(self, widget, playlist_id):
+ _iter = self.playlists_model.get_iter_first()
+ while _iter:
+ playlist = self.playlists_model.get_value(_iter, 5)
+ if str(playlist_id) == playlist.get_id() and self.current_playlist == playlist:
+ path = self.playlists_model.get_path(_iter)
+ self._on_playlist_activated(None, None, path, skip_cache=True)
+ selection = self.playlists_sidebar.get_generic_view().get_selection()
+ selection.select_iter(_iter)
+ break
+ _iter = self.playlists_model.iter_next(_iter)
+
+ @log
def activate_playlist(self, playlist_id):
+
def find_and_activate_playlist():
for playlist in self.playlists_model:
if playlist[5].get_id() == playlist_id:
@@ -999,7 +1014,7 @@ class Playlist(ViewContainer):
self._populate()
@log
- def _on_playlist_activated(self, widget, item_id, path):
+ def _on_playlist_activated(self, widget, item_id, path, skip_cache=False):
_iter = self.playlists_model.get_iter(path)
playlist_name = self.playlists_model.get_value(_iter, 2)
playlist = self.playlists_model.get_value(_iter, 5)
@@ -1013,7 +1028,7 @@ class Playlist(ViewContainer):
# if the active queue has been set by this playlist,
# use it as model, otherwise build the liststore
cached_playlist = self.player.running_playlist('Playlist', playlist_name)
- if cached_playlist:
+ if cached_playlist and not skip_cache:
self._model = cached_playlist
currentTrack = self.player.playlist.get_iter(self.player.currentTrack.get_path())
self.update_model(self.player, cached_playlist,
@@ -1487,6 +1502,10 @@ class Search(ViewContainer):
return model.iter_parent(_iter) is not None or model.iter_has_child(_iter)
@log
+ def _on_grilo_ready(self, data=None):
+ playlists.fetch_or_create_static_playlists()
+
+ @log
def set_search_text(self, search_term, fields_filter):
query_matcher = {
'album': {
diff --git a/gnomemusic/window.py b/gnomemusic/window.py
index 2ce4c9b..66bb56c 100644
--- a/gnomemusic/window.py
+++ b/gnomemusic/window.py
@@ -44,13 +44,13 @@ from gnomemusic import log
import logging
logger = logging.getLogger(__name__)
-playlist = Playlists.get_default()
try:
tracker = Tracker.SparqlConnection.get(None)
except Exception as e:
from sys import exit
logger.error("Cannot connect to tracker, error '%s'\Exiting" % str(e))
exit(1)
+playlist = Playlists.get_default(tracker)
class Window(Gtk.ApplicationWindow):
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]