1# Copyright (c) 2014-2021 Cedric Bellegarde <cedric.bellegarde@adishatz.org>
2# This program is free software: you can redistribute it and/or modify
3# it under the terms of the GNU General Public License as published by
4# the Free Software Foundation, either version 3 of the License, or
5# (at your option) any later version.
6# This program is distributed in the hope that it will be useful,
7# but WITHOUT ANY WARRANTY; without even the implied warranty of
8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9# GNU General Public License for more details.
10# You should have received a copy of the GNU General Public License
11# along with this program. If not, see <http://www.gnu.org/licenses/>.
12
13from gi.repository import GObject, GLib, Gio, TotemPlParser
14
15from gettext import gettext as _
16import itertools
17import sqlite3
18from datetime import datetime
19from threading import Lock
20import json
21
22from lollypop.database import Database
23from lollypop.define import App, Type
24from lollypop.objects_track import Track
25from lollypop.sqlcursor import SqlCursor
26from lollypop.localized import LocalizedCollation
27from lollypop.shown import ShownPlaylists
28from lollypop.utils import emit_signal, get_default_storage_type
29from lollypop.utils_file import get_mtime
30from lollypop.logger import Logger
31from lollypop.database_upgrade import DatabasePlaylistsUpgrade
32
33
34class Playlists(GObject.GObject):
35    """
36        Playlists manager
37    """
38    __LOCAL_PATH = GLib.get_user_data_dir() + "/lollypop"
39    _DB_PATH = "%s/playlists.db" % __LOCAL_PATH
40    __gsignals__ = {
41        "playlists-added": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
42        "playlists-removed": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
43        "playlists-updated": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
44        "playlists-renamed": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
45        "playlist-track-added": (
46            GObject.SignalFlags.RUN_FIRST, None, (int, str)),
47        "playlist-track-removed": (
48            GObject.SignalFlags.RUN_FIRST, None, (int, str))
49    }
50    __create_playlists = """CREATE TABLE playlists (
51                            id INTEGER PRIMARY KEY,
52                            name TEXT NOT NULL,
53                            synced INT NOT NULL DEFAULT 0,
54                            smart_enabled INT NOT NULL DEFAULT 0,
55                            smart_sql TEXT,
56                            uri TEXT,
57                            mtime BIGINT NOT NULL)"""
58
59    __create_tracks = """CREATE TABLE tracks (
60                        playlist_id INT NOT NULL,
61                        uri TEXT NOT NULL)"""
62
63    def __init__(self):
64        """
65            Init playlists manager
66        """
67        self.thread_lock = Lock()
68        GObject.GObject.__init__(self)
69        upgrade = DatabasePlaylistsUpgrade()
70        # Create db schema
71        f = Gio.File.new_for_path(self._DB_PATH)
72        if not f.query_exists():
73            try:
74                with SqlCursor(self, True) as sql:
75                    sql.execute(self.__create_playlists)
76                    sql.execute(self.__create_tracks)
77                    sql.execute("PRAGMA user_version=%s" % upgrade.version)
78            except:
79                pass
80        else:
81            upgrade.upgrade(self)
82
83    def get_new_name(self):
84        """
85            Get a name for a new playlist
86            @return str
87        """
88        existing_playlists = []
89        for (playlist_id, name) in App().playlists.get():
90            existing_playlists.append(name)
91
92        # Search for an available name
93        count = 1
94        name = _("New playlist ") + str(count)
95        while name in existing_playlists:
96            count += 1
97            name = _("New playlist ") + str(count)
98        return name
99
100    def add(self, name):
101        """
102            Add a playlist
103            @param name as str
104            @return playlist_id as int
105            @thread safe
106        """
107        if name == _("Loved tracks"):
108            return Type.LOVED
109        lastrowid = 0
110        with SqlCursor(self, True) as sql:
111            result = sql.execute("INSERT INTO playlists (name, mtime)\
112                                  VALUES (?, ?)",
113                                 (name, 0))
114            lastrowid = result.lastrowid
115        emit_signal(self, "playlists-added", lastrowid)
116        return lastrowid
117
118    def exists(self, playlist_id):
119        """
120            Return True if playlist exists
121            @param playlist_id as int
122        """
123        with SqlCursor(self) as sql:
124            result = sql.execute("SELECT rowid\
125                                  FROM playlists\
126                                  WHERE rowid=?",
127                                 (playlist_id,))
128            v = result.fetchone()
129            if v is not None:
130                return True
131            else:
132                return False
133
134    def rename(self, playlist_id, name):
135        """
136            Rename playlist
137            @param playlist_id as int
138            @param name as str
139        """
140        with SqlCursor(self, True) as sql:
141            sql.execute("UPDATE playlists\
142                        SET name=?\
143                        WHERE rowid=?",
144                        (name, playlist_id))
145        emit_signal(self, "playlists-renamed", playlist_id)
146        App().art.remove_from_cache("playlist_" + name, "ROUNDED")
147
148    def remove(self, playlist_id):
149        """
150            Remove playlist
151            @param playlist_id as int
152        """
153        name = self.get_name(playlist_id)
154        with SqlCursor(self, True) as sql:
155            sql.execute("DELETE FROM playlists\
156                        WHERE rowid=?",
157                        (playlist_id,))
158            sql.execute("DELETE FROM tracks\
159                        WHERE playlist_id=?",
160                        (playlist_id,))
161        emit_signal(self, "playlists-removed", playlist_id)
162        App().art.remove_from_cache("playlist_" + name, "ROUNDED")
163
164    def clear(self, playlist_id):
165        """
166            Clear playlsit
167            @param playlist_id as int
168        """
169        with SqlCursor(self, True) as sql:
170            sql.execute("DELETE FROM tracks\
171                         WHERE playlist_id=?", (playlist_id,))
172        self.sync_to_disk(playlist_id)
173
174    def add_uri(self, playlist_id, uri, signal=False):
175        """
176            Add uri to playlist
177            @param playlist_id as int
178            @param uri as str
179            @param signal as bool
180        """
181        if self.exists_track(playlist_id, uri):
182            return
183        with SqlCursor(self, True) as sql:
184            sql.execute("INSERT INTO tracks VALUES (?, ?)", (playlist_id, uri))
185        if signal:
186            emit_signal(self, "playlist-track-added", playlist_id, uri)
187
188    def add_uris(self, playlist_id, uris, signal=False):
189        """
190            Add uris to playlists (even if exists)
191            @param playlist_id as int
192            @param uris as [str]
193            @param signal as bool
194        """
195        for uri in uris:
196            self.add_uri(playlist_id, uri, signal)
197        self.sync_to_disk(playlist_id)
198
199    def add_tracks(self, playlist_id, tracks, signal=False):
200        """
201            Add tracks to playlist
202            @param playlist_id as int
203            @param tracks as [Track]
204            @param signal as bool
205        """
206        for track in tracks:
207            self.add_uri(playlist_id, track.uri, signal)
208        self.sync_to_disk(playlist_id)
209
210    def remove_uri(self, playlist_id, uri, signal=False):
211        """
212            Remove uri from playlist
213            @param playlist_id as int
214            @param uri a str
215            @param signal as bool
216        """
217        if not self.exists_track(playlist_id, uri):
218            return
219        with SqlCursor(self, True) as sql:
220            sql.execute("DELETE FROM tracks WHERE uri=? AND playlist_id=?",
221                        (uri, playlist_id))
222        if signal:
223            emit_signal(self, "playlist-track-removed", playlist_id, uri)
224
225    def remove_uris(self, playlist_id, uris, signal=False):
226        """
227            Remove uris from playlist
228            @param playlist_id as int
229            @param uris as [str]
230            @param signal as bool
231        """
232        for uri in uris:
233            self.remove_uri(playlist_id, uri, signal)
234        self.sync_to_disk(playlist_id)
235
236    def remove_tracks(self, playlist_id, tracks, signal=False):
237        """
238            Remove tracks from playlist
239            @param playlist_id as int
240            @param tracks as [Track]
241            @param signal as bool
242        """
243        for track in tracks:
244            self.remove_uri(playlist_id, track.uri, signal)
245        self.sync_to_disk(playlist_id)
246
247    def get(self):
248        """
249            Return availables playlists
250            @return [int, str, str]
251        """
252        with SqlCursor(self) as sql:
253            result = sql.execute("SELECT rowid, name\
254                                  FROM playlists\
255                                  ORDER BY name\
256                                  COLLATE NOCASE COLLATE LOCALIZED")
257            return list(result)
258
259    def get_ids(self):
260        """
261            Return availables playlists
262            @return [int]
263        """
264        with SqlCursor(self) as sql:
265            result = sql.execute("SELECT rowid\
266                                  FROM playlists\
267                                  ORDER BY name\
268                                  COLLATE NOCASE COLLATE LOCALIZED")
269            return list(itertools.chain(*result))
270
271    def get_track_uris(self, playlist_id):
272        """
273            Return available track uris for playlist
274            @param playlist_id as int
275            @return [str]
276        """
277        with SqlCursor(self) as sql:
278            result = sql.execute("SELECT uri\
279                                  FROM tracks\
280                                  WHERE playlist_id=?", (playlist_id,))
281            return list(itertools.chain(*result))
282
283    def get_smart_track_uris(self, playlist_id):
284        """
285            Return available track uris for playlist
286            @param playlist_id as int
287            @return [str]
288        """
289        request = self.get_smart_sql(playlist_id)
290        # We need to inject skipped/storage_type
291        storage_type = get_default_storage_type()
292        split = request.split("ORDER BY")
293        split[0] += " AND tracks.loved != %s" % Type.NONE
294        split[0] += " AND tracks.storage_type&%s " % storage_type
295        track_ids = App().db.execute("ORDER BY".join(split))
296        return [Track(track_id).uri for track_id in track_ids]
297
298    def get_track_ids(self, playlist_id):
299        """
300            Return availables track ids for playlist
301            @param playlist_id as int
302            @return [int]
303        """
304        track_ids = []
305        limit = App().settings.get_value("view-limit").get_int32()
306        storage_type = get_default_storage_type()
307        if playlist_id == Type.POPULARS:
308            track_ids = App().tracks.get_populars([], storage_type,
309                                                  False, limit)
310        elif playlist_id == Type.RECENTS:
311            track_ids = App().tracks.get_recently_listened_to(storage_type,
312                                                              False,
313                                                              limit)
314        elif playlist_id == Type.LITTLE:
315            track_ids = App().tracks.get_little_played(storage_type,
316                                                       False,
317                                                       limit)
318        elif playlist_id == Type.RANDOMS:
319            track_ids = App().tracks.get_randoms([], storage_type,
320                                                 False, limit)
321        elif playlist_id == Type.SKIPPED:
322            track_ids = App().tracks.get_skipped(storage_type)
323        elif playlist_id == Type.ALL:
324            track_ids = App().tracks.get_ids(storage_type, False)
325        elif playlist_id == Type.LOVED:
326            track_ids = App().tracks.get_loved_track_ids([], storage_type)
327        else:
328            with SqlCursor(self) as sql:
329                result = sql.execute("SELECT music.tracks.rowid\
330                                      FROM tracks, music.tracks\
331                                      WHERE tracks.playlist_id=?\
332                                      AND music.tracks.uri=\
333                                      main.tracks.uri",
334                                     (playlist_id,))
335                track_ids = list(itertools.chain(*result))
336        return track_ids
337
338    def get_tracks(self, playlist_id):
339        """
340            Return availables tracks for playlist
341            @param playlist_id as int
342            @return [Track]
343        """
344        return [Track(track_id)
345                for track_id in self.get_track_ids(playlist_id)]
346
347    def get_duration(self, playlist_id):
348        """
349            Return playlist duration
350            @param playlist_id as int
351            @return duration as int
352        """
353        with SqlCursor(self) as sql:
354            result = sql.execute("SELECT SUM(music.tracks.duration)\
355                                  FROM tracks, music.tracks\
356                                  WHERE tracks.playlist_id=?\
357                                  AND music.tracks.uri=\
358                                  main.tracks.uri",
359                                 (playlist_id,))
360            v = result.fetchone()
361            if v is not None and v[0] is not None:
362                return v[0]
363            return 0
364
365    def get_id(self, playlist_name):
366        """
367            Get playlist id
368            @param playlist_name as str
369            @return playlst id as int
370        """
371        if playlist_name == _("Loved tracks"):
372            return Type.LOVED
373
374        with SqlCursor(self) as sql:
375            result = sql.execute("SELECT rowid\
376                                 FROM playlists\
377                                 WHERE name=?", (playlist_name,))
378            v = result.fetchone()
379            if v is not None:
380                return v[0]
381            return None
382
383    def get_name(self, playlist_id):
384        """
385            Get playlist name
386            @param playlist_id as int
387            @return playlist name as str
388        """
389        if playlist_id < 0:
390            for (id, name, sortname) in ShownPlaylists.get(True):
391                if id == playlist_id:
392                    return name
393
394        with SqlCursor(self) as sql:
395            result = sql.execute("SELECT name\
396                                 FROM playlists\
397                                 WHERE rowid=?", (playlist_id,))
398            v = result.fetchone()
399            if v is not None:
400                return v[0]
401            return ""
402
403    def get_synced(self, playlist_id, index):
404        """
405            True if playlist synced
406            @param playlist_id as int
407            @param index as int
408            @return bool
409        """
410        if playlist_id < 0:
411            internal_ids = App().settings.get_value(
412                    "sync-internal-ids").get_string()
413            try:
414                data = json.loads(internal_ids)
415                data.keys()
416            except:
417                data = {}
418            synced_ids = []
419            for synced_id in data.keys():
420                if data[synced_id] & (1 << index):
421                    synced_ids.append(int(synced_id))
422            return playlist_id in synced_ids
423        else:
424            with SqlCursor(self) as sql:
425                result = sql.execute("SELECT synced\
426                                     FROM playlists\
427                                     WHERE rowid=?", (playlist_id,))
428                v = result.fetchone()
429                if v is not None:
430                    return v[0] & (1 << index)
431                return False
432
433    def get_synced_ids(self, index):
434        """
435            Return availables synced playlists
436            @return [int]
437        """
438        with SqlCursor(self) as sql:
439            internal_ids = App().settings.get_value(
440                    "sync-internal-ids").get_string()
441            try:
442                data = json.loads(internal_ids)
443                data.keys()
444            except:
445                data = {}
446            synced_ids = []
447            for playlist_id in data.keys():
448                if data[playlist_id] & (1 << index):
449                    synced_ids.append(int(playlist_id))
450            result = sql.execute("SELECT rowid\
451                                  FROM playlists\
452                                  WHERE synced & (1 << ?)\
453                                  ORDER BY name\
454                                  COLLATE NOCASE COLLATE LOCALIZED",
455                                 (index,))
456            return list(itertools.chain(*result)) + synced_ids
457
458    def get_smart(self, playlist_id):
459        """
460            True if playlist is smart
461            @param playlist_id as int
462            @return bool
463        """
464        with SqlCursor(self) as sql:
465            result = sql.execute("SELECT smart_enabled\
466                                 FROM playlists\
467                                 WHERE rowid=?", (playlist_id,))
468            v = result.fetchone()
469            if v is not None:
470                return v[0]
471            return False
472
473    def get_smart_sql(self, playlist_id):
474        """
475            Get SQL smart request
476            @param playlist_id as int
477            @return str
478        """
479        with SqlCursor(self) as sql:
480            result = sql.execute("SELECT smart_sql\
481                                 FROM playlists\
482                                 WHERE rowid=?", (playlist_id,))
483            v = result.fetchone()
484            if v is not None:
485                return v[0]
486            return None
487
488    def set_synced(self, playlist_id, synced):
489        """
490            Mark playlist as synced
491            @param playlist_id as int
492            @param synced as bool
493        """
494        if playlist_id < 0:
495            internal_ids = App().settings.get_value(
496                "sync-internal-ids").get_string()
497            try:
498                data = json.loads(internal_ids)
499                data.keys()
500            except:
501                data = {}
502            data[str(playlist_id)] = synced
503            internal_ids = json.dumps(data)
504            App().settings.set_value("sync-internal-ids",
505                                     GLib.Variant("s", internal_ids))
506        else:
507            with SqlCursor(self, True) as sql:
508                sql.execute("UPDATE playlists\
509                            SET synced=?\
510                            WHERE rowid=?",
511                            (synced, playlist_id))
512
513    def set_smart(self, playlist_id, smart):
514        """
515            Mark playlist as smart
516            @param playlist_id as int
517            @param smart as bool
518        """
519        with SqlCursor(self, True) as sql:
520            sql.execute("UPDATE playlists\
521                        SET smart_enabled=?\
522                        WHERE rowid=?",
523                        (smart, playlist_id))
524            emit_signal(self, "playlists-updated", playlist_id)
525
526    def set_smart_sql(self, playlist_id, request):
527        """
528            Set playlist SQL smart request
529            @param playlist_id as int
530            @param request as str
531        """
532        name = self.get_name(playlist_id)
533        # Clear cache
534        App().art.remove_from_cache("playlist_" + name, "ROUNDED")
535        with SqlCursor(self, True) as sql:
536            sql.execute("UPDATE playlists\
537                        SET smart_sql=?\
538                        WHERE rowid=?",
539                        (request, playlist_id))
540            emit_signal(self, "playlists-updated", playlist_id)
541
542    def get_position(self, playlist_id, track_id):
543        """
544            Get track position in playlist
545            @param playlist_id as int
546            @param track_id as int
547            @return position as int
548        """
549        i = 0
550        for tid in self.get_track_ids(playlist_id):
551            if track_id == tid:
552                break
553            i += 1
554        return i
555
556    def exists_track(self, playlist_id, uri):
557        """
558            Check if track id exist in playlist
559            @param playlist_id as int
560            @param uri as str
561            @return bool
562        """
563        with SqlCursor(self) as sql:
564            result = sql.execute("SELECT uri\
565                                  FROM tracks\
566                                  WHERE playlist_id=?\
567                                  AND uri=?", (playlist_id, uri))
568            v = result.fetchone()
569            if v is not None:
570                return True
571            return False
572
573    def set_sync_uri(self, playlist_id, uri):
574        """
575            Set sync URI
576            @param playlist_id as int
577            @param uri as str
578        """
579        with SqlCursor(self, True) as sql:
580            sql.execute("UPDATE playlists\
581                        SET uri=?\
582                        WHERE rowid=?", (uri, playlist_id))
583
584    def get_sync_uri(self, playlist_id):
585        """
586            Get sync URI
587            @param playlist_id as int
588            @return str/None
589        """
590        with SqlCursor(self) as sql:
591            result = sql.execute("SELECT uri\
592                                  FROM playlists\
593                                  WHERE rowid=?", (playlist_id,))
594            v = result.fetchone()
595            if v is not None:
596                return v[0]
597            return None
598
599    def sync_to_disk(self, playlist_id, create=False):
600        """
601            Sync playlist_id to disk
602            @param playlist_id as int
603            @param create as bool => create file
604        """
605        try:
606            name = self.get_name(playlist_id)
607            # Clear cache
608            App().art.remove_from_cache("playlist_" + name, "ROUNDED")
609            playlist_uri = self.get_sync_uri(playlist_id)
610            if playlist_uri is None:
611                return
612            f = Gio.File.new_for_uri(playlist_uri)
613            if not f.query_exists() and not create:
614                return
615            if self.get_smart(playlist_id):
616                uris = self.get_smart_track_uris(playlist_id)
617            else:
618                uris = self.get_track_uris(playlist_id)
619            if not uris:
620                return
621            stream = f.replace(None, False,
622                               Gio.FileCreateFlags.REPLACE_DESTINATION, None)
623            stream.write("#EXTM3U\n".encode("utf-8"))
624            playlist_dir_uri = playlist_uri.replace(f.get_basename(), "")
625            for uri in uris:
626                if uri.startswith("web://"):
627                    continue
628                if playlist_dir_uri in uri:
629                    filepath = uri.replace(playlist_dir_uri, "")
630                    string = "%s\n" % GLib.uri_unescape_string(filepath, None)
631                else:
632                    string = "%s\n" % uri
633                stream.write(string.encode("utf-8"))
634            stream.close()
635        except Exception as e:
636            Logger.error("Playlists::sync_to_disk(): %s", e)
637
638    def exists_album(self, playlist_id, album):
639        """
640            Return True if object_id is already present in playlist
641            @param playlist_id as int
642            @param album as Album/Disc
643            @return bool
644        """
645        # We do not use Album object for performance reasons
646        playlist_uris = self.get_track_uris(playlist_id)
647        track_uris = album.track_uris
648        return len(set(playlist_uris) & set(track_uris)) == len(track_uris)
649
650    def remove_device(self, index):
651        """
652            Remove device from DB
653            @param index as int => device index
654        """
655        with SqlCursor(self, True) as sql:
656            sql.execute("UPDATE playlists SET synced = synced & ~(1<<?)",
657                        (index,))
658
659    def import_tracks(self, uri):
660        """
661            Import file as playlist
662            @param uri as str
663        """
664        f = Gio.File.new_for_uri(uri)
665        # Create playlist and get id
666        basename = ".".join(f.get_basename().split(".")[:-1])
667        playlist_id = self.get_id(basename)
668        # Do not reimport playlists
669        if playlist_id is not None:
670            return
671        playlist_id = self.add(basename)
672        # Check mtime has been updated
673        with SqlCursor(self) as sql:
674            result = sql.execute("SELECT mtime\
675                                 FROM playlists\
676                                 WHERE rowid=?", (playlist_id,))
677            v = result.fetchone()
678            if v is not None:
679                db_mtime = v[0]
680            else:
681                db_mtime = 0
682            info = f.query_info(Gio.FILE_ATTRIBUTE_TIME_MODIFIED,
683                                Gio.FileQueryInfoFlags.NONE, None)
684            mtime = get_mtime(info)
685            if db_mtime >= mtime:
686                return
687
688        # Load playlist
689        parser = TotemPlParser.Parser.new()
690        uris = []
691        parser.connect("entry-parsed", self.__on_entry_parsed,
692                       playlist_id, uris)
693        parser.parse_async(f.get_uri(), True,
694                           None, self.__on_parse_finished,
695                           playlist_id, uris)
696
697    def get_cursor(self):
698        """
699            Return a new sqlite cursor
700        """
701        try:
702            sql = sqlite3.connect(self._DB_PATH, 600.0)
703            sql.execute('ATTACH DATABASE "%s" AS music' % Database.DB_PATH)
704            sql.create_collation("LOCALIZED", LocalizedCollation())
705            return sql
706        except:
707            exit(-1)
708
709#######################
710# PRIVATE             #
711#######################
712    def __on_parse_finished(self, parser, result, playlist_id, uris):
713        """
714            Add tracks to playlists
715            @param parser as TotemPlParser.Parser
716            @param result as Gio.AsyncResult
717            @param playlist_id as int
718            @param uris as [str]
719        """
720        self.clear(playlist_id)
721        self.add_uris(playlist_id, uris)
722        with SqlCursor(self, True) as sql:
723            sql.execute("UPDATE playlists SET mtime=?\
724                         WHERE rowid=?", (datetime.now().strftime("%s"),
725                                          playlist_id))
726
727    def __on_entry_parsed(self, parser, uri, metadata, playlist_id, uris):
728        """
729            Play stream
730            @param parser as TotemPlParser.Parser
731            @param track uri as str
732            @param metadata as GLib.HastTable
733            @param playlist_id as int
734            @param uris as [str]
735        """
736        uris.append(uri)
737