[gnome-music] Initial smart playlists implementation



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]