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