1# Copyright (C) 2008-2010 Adam Olsen 2# 3# This program is free software; you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation; either version 2, or (at your option) 6# any later version. 7# 8# This program is distributed in the hope that it will be useful, 9# but WITHOUT ANY WARRANTY; without even the implied warranty of 10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11# GNU General Public License for more details. 12# 13# You should have received a copy of the GNU General Public License 14# along with this program; if not, write to the Free Software 15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16# 17# 18# The developers of the Exaile media player hereby grant permission 19# for non-GPL compatible GStreamer and Exaile plugins to be used and 20# distributed together with GStreamer and Exaile. This permission is 21# above and beyond the permissions granted by the GPL license by which 22# Exaile is covered. If you modify this code, you may extend this 23# exception to your version of the code, but you are not obligated to 24# do so. If you do not wish to do so, delete this exception statement 25# from your version. 26 27""" 28Provides the fundamental objects for handling a list of tracks contained 29in playlists as well as methods to import and export from various file formats. 30""" 31 32from gi.repository import Gio 33 34from collections import deque 35from datetime import datetime, timedelta 36import logging 37import operator 38import os 39import pickle 40import random 41import re 42import time 43from typing import NamedTuple 44import urllib.parse 45import urllib.request 46 47from xl import common, dynamic, event, main, providers, settings, trax, xdg 48from xl.common import GioFileInputStream, GioFileOutputStream, MetadataList 49from xl.nls import gettext as _ 50from xl.metadata.tags import tag_data 51 52logger = logging.getLogger(__name__) 53 54 55class InvalidPlaylistTypeError(Exception): 56 pass 57 58 59class PlaylistExists(Exception): 60 pass 61 62 63class UnknownPlaylistTrackError(Exception): 64 pass 65 66 67class PlaylistExportOptions(NamedTuple): 68 relative: bool 69 70 71def encode_filename(filename: str) -> str: 72 """ 73 Converts a file name into a valid filename most 74 likely to not cause problems on any platform. 75 76 :param filename: the name of the file 77 """ 78 # list of invalid chars that need to be encoded 79 # Note: '%' is the prefix for encoded chars so blacklist it too 80 blacklist = r'<>:"/\|?*%' 81 82 def encode_char(c): 83 return '%' + hex(ord(c))[2:] if c in blacklist else c 84 85 # encode any blacklisted chars 86 filename = ''.join(map(encode_char, filename)) + '.playlist' 87 88 return filename 89 90 91def is_valid_playlist(path): 92 """ 93 Returns whether the file at a given path is a valid 94 playlist. Checks for content type and falls back to 95 file extension if unknown. 96 97 :param path: the source path 98 :type path: string 99 """ 100 content_type = Gio.content_type_guess(path)[0] 101 102 if not Gio.content_type_is_unknown(content_type): 103 for provider in providers.get('playlist-format-converter'): 104 if content_type in provider.content_types: 105 return True 106 107 file_extension = path.split('.')[-1] 108 109 for provider in providers.get('playlist-format-converter'): 110 if file_extension in provider.file_extensions: 111 return True 112 113 return False 114 115 116def import_playlist(path): 117 """ 118 Determines the type of playlist and creates 119 a playlist from it 120 121 :param path: the source path 122 :type path: string 123 :returns: the playlist 124 :rtype: :class:`Playlist` 125 """ 126 # First try the cheap Gio way 127 content_type = Gio.content_type_guess(path)[0] 128 129 if not Gio.content_type_is_unknown(content_type): 130 for provider in providers.get('playlist-format-converter'): 131 if content_type in provider.content_types: 132 return provider.import_from_file(path) 133 134 # Next try to extract the file extension via URL parsing 135 file_extension = urllib.parse.urlparse(path).path.split('.')[-1] 136 137 for provider in providers.get('playlist-format-converter'): 138 if file_extension in provider.file_extensions: 139 return provider.import_from_file(path) 140 141 # Last try the expensive Gio way (downloads the data for inspection) 142 content_type = ( 143 Gio.File.new_for_uri(path) 144 .query_info('standard::content-type') 145 .get_content_type() 146 ) 147 148 if content_type: 149 for provider in providers.get('playlist-format-converter'): 150 if content_type in provider.content_types: 151 return provider.import_from_file(path) 152 153 raise InvalidPlaylistTypeError(_('Invalid playlist type.')) 154 155 156def export_playlist(playlist, path, options=None): 157 """ 158 Exact same as @see import_playlist except 159 it exports 160 """ 161 file_extension = path.split('.')[-1] 162 163 if hasattr(playlist, 'get_playlist'): 164 playlist = playlist.get_playlist() 165 if playlist is None: 166 raise InvalidPlaylistTypeError( 167 "SmartPlaylist not associated with a collection" 168 ) 169 170 for provider in providers.get('playlist-format-converter'): 171 if file_extension in provider.file_extensions: 172 provider.export_to_file(playlist, path, options) 173 break 174 else: 175 raise InvalidPlaylistTypeError(_('Invalid playlist type.')) 176 177 178class FormatConverter: 179 """ 180 Base class for all converters allowing to 181 import from and export to a specific format 182 """ 183 184 title = _('Playlist') 185 content_types = [] 186 file_extensions = property(lambda self: [self.name]) 187 188 def __init__(self, name): 189 self.name = name 190 191 def export_to_file(self, playlist, path, options=None): 192 """ 193 Export a playlist to a given path 194 195 :param playlist: the playlist 196 :type playlist: :class:`Playlist` 197 :param path: the target path 198 :type path: string 199 :param options: exporting options 200 :type options: :class:`PlaylistExportOptions` 201 """ 202 pass 203 204 def import_from_file(self, path): 205 """ 206 Import a playlist from a given path 207 208 :param path: the source path 209 :type path: string 210 :returns: the playlist 211 :rtype: :class:`Playlist` 212 """ 213 pass 214 215 def name_from_path(self, path): 216 """ 217 Convenience method to retrieve a sane 218 name from a path 219 220 :param path: the source path 221 :type path: string 222 :returns: a name 223 :rtype: string 224 """ 225 gfile = Gio.File.new_for_uri(path) 226 name = gfile.get_basename() 227 228 for extension in self.file_extensions: 229 if name.endswith(extension): 230 # Remove known extension 231 return name[: -len(extension) - 1] 232 return name 233 234 def get_track_import_path(self, playlist_path, track_path): 235 """ 236 Retrieves the import path of a track 237 238 :param playlist_path: the import path of the playlist 239 :type playlist_path: string 240 :param track_path: the path of the track 241 :type track_path: string 242 """ 243 playlist_uri = Gio.File.new_for_uri(playlist_path).get_uri() 244 # Track path will not be changed if it already is a fully qualified URL 245 track_uri = urllib.parse.urljoin(playlist_uri, track_path.replace('\\', '/')) 246 247 logging.debug('Importing track: %s' % track_uri) 248 249 # Now, let's be smart about importing the file/playlist. If the 250 # original URI cannot be found and its a local path, then do a 251 # small search for the track relative to the playlist to see 252 # if it can be found. 253 254 # TODO: Scan collection for tracks as last resort?? 255 256 if track_uri.startswith('file:///') and not Gio.File.new_for_uri( 257 track_uri 258 ).query_exists(None): 259 260 if not playlist_uri.startswith('file:///'): 261 logging.debug('Track does not seem to exist, using original path') 262 else: 263 logging.debug( 264 'Track does not seem to exist, trying different path combinations' 265 ) 266 267 def _iter_uris(pp, tp): 268 pps = pp[len('file:///') :].split('/') 269 tps = tp.strip().replace('\\', '/').split('/') 270 271 # handle absolute paths correctly 272 if tps[0] == '': 273 tps = tps[1:] 274 275 # iterate the playlist path a/b/c/d, a/b/c, a/b, ... 276 for p in range(len(pps) - 1, 0, -1): 277 ppp = 'file:///%s' % '/'.join(pps[0:p]) 278 279 # iterate the file path d, c/d, b/c/d, ... 280 for t in range(len(tps) - 1, -1, -1): 281 yield '%s/%s' % (ppp, '/'.join(tps[t : len(tps)])) 282 283 for uri in _iter_uris(playlist_uri, track_path): 284 logging.debug('Trying %s' % uri) 285 if Gio.File.new_for_uri(uri).query_exists(None): 286 track_uri = uri 287 logging.debug('Track found at %s' % uri) 288 break 289 290 return track_uri 291 292 def get_track_export_path( 293 self, playlist_path: str, track_path: str, options: PlaylistExportOptions 294 ): 295 """ 296 Retrieves the export path of a track, 297 possibly influenced by options 298 299 :param playlist_path: the export path of the playlist 300 :param track_path: the path of the track 301 :param options: options 302 """ 303 if options is not None and options.relative: 304 playlist_file = Gio.File.new_for_uri(playlist_path) 305 # Strip playlist filename from export path 306 export_path = playlist_file.get_parent().get_uri() 307 308 try: 309 export_path_components = urllib.parse.urlparse(export_path) 310 track_path_components = urllib.parse.urlparse(track_path) 311 except (AttributeError, ValueError): # None, empty path 312 pass 313 else: 314 # Only try to retrieve relative paths for tracks with 315 # the same URI scheme and location as the playlist 316 if ( 317 export_path_components.scheme == track_path_components.scheme 318 and export_path_components.netloc == track_path_components.netloc 319 ): 320 # Gio.File.get_relative_path does not generate relative paths 321 # for tracks located above the playlist in the path hierarchy, 322 # thus process both paths as done here 323 track_path = os.path.relpath( 324 track_path_components.path, export_path_components.path 325 ) 326 327 # if the file is local, other players like VLC will not 328 # accept the playlist if they have %20 in them, so we must convert 329 # it to something else 330 return urllib.request.url2pathname(track_path) 331 332 333class M3UConverter(FormatConverter): 334 """ 335 Import from and export to M3U format 336 """ 337 338 title = _('M3U Playlist') 339 content_types = ['audio/x-mpegurl', 'audio/mpegurl'] 340 341 def __init__(self): 342 FormatConverter.__init__(self, 'm3u') 343 344 def export_to_file(self, playlist, path, options=None): 345 """ 346 Export a playlist to a given path 347 348 :param playlist: the playlist 349 :type playlist: :class:`Playlist` 350 :param path: the target path 351 :type path: string 352 :param options: exporting options 353 :type options: :class:`PlaylistExportOptions` 354 """ 355 with GioFileOutputStream(Gio.File.new_for_uri(path)) as stream: 356 stream.write('#EXTM3U\n') 357 358 if playlist.name: 359 stream.write('#PLAYLIST: {name}\n'.format(name=playlist.name)) 360 361 for track in playlist: 362 title = [ 363 track.get_tag_display('title', join=True), 364 track.get_tag_display('artist', join=True), 365 ] 366 length = int(round(float(track.get_tag_raw('__length') or -1))) 367 368 track_path = track.get_loc_for_io() 369 track_path = self.get_track_export_path(path, track_path, options) 370 371 stream.write( 372 '#EXTINF:{length},{title}\n{path}\n'.format( 373 length=length, 374 title=' - '.join(title), 375 path=track_path, 376 ) 377 ) 378 379 def import_from_file(self, path): 380 """ 381 Import a playlist from a given path 382 383 :param path: the source path 384 :type path: string 385 :returns: the playlist 386 :rtype: :class:`Playlist` 387 """ 388 playlist = Playlist(name=self.name_from_path(path)) 389 extinf = {} 390 lineno = 0 391 392 logger.debug('Importing M3U playlist: %s', path) 393 394 with GioFileInputStream(Gio.File.new_for_uri(path)) as stream: 395 for line in stream: 396 lineno += 1 397 398 line = line.strip() 399 400 if not line: 401 continue 402 403 if line.upper().startswith('#PLAYLIST: '): 404 playlist.name = line[len('#PLAYLIST: ') :] 405 elif line.startswith('#EXTINF:'): 406 extinf_line = line[len('#EXTINF:') :] 407 408 parts = extinf_line.split(',', 1) 409 length = 0 410 411 if len(parts) > 1 and int(parts[0]) > 0: 412 length = int(float(parts[0])) 413 414 extinf['__length'] = length 415 416 parts = parts[-1].rsplit(' - ', 1) 417 418 extinf['title'] = parts[-1] 419 420 if len(parts) > 1: 421 extinf['artist'] = parts[0] 422 elif line.startswith('#'): 423 continue 424 else: 425 track = trax.Track(self.get_track_import_path(path, line)) 426 427 if extinf: 428 for tag, value in extinf.items(): 429 if track.get_tag_raw(tag) is None: 430 try: 431 track.set_tag_raw(tag, value) 432 except Exception as e: 433 # Python 3: raise UnknownPlaylistTrackError() from e 434 # Python 2: .. no good solution 435 raise UnknownPlaylistTrackError( 436 "line %s: %s" % (lineno, e) 437 ) 438 439 playlist.append(track) 440 extinf = {} 441 442 return playlist 443 444 445providers.register('playlist-format-converter', M3UConverter()) 446 447 448class PLSConverter(FormatConverter): 449 """ 450 Import from and export to PLS format 451 """ 452 453 title = _('PLS Playlist') 454 content_types = ['audio/x-scpls'] 455 456 def __init__(self): 457 FormatConverter.__init__(self, 'pls') 458 459 def export_to_file(self, playlist, path, options=None): 460 """ 461 Export a playlist to a given path 462 463 :param playlist: the playlist 464 :type playlist: :class:`Playlist` 465 :param path: the target path 466 :type path: string 467 :param options: exporting options 468 :type options: :class:`PlaylistExportOptions` 469 """ 470 from configparser import RawConfigParser 471 472 pls_playlist = RawConfigParser() 473 pls_playlist.optionxform = str # Make case sensitive 474 pls_playlist.add_section('playlist') 475 pls_playlist.set('playlist', 'NumberOfEntries', len(playlist)) 476 477 for index, track in enumerate(playlist): 478 position = index + 1 479 title = [ 480 track.get_tag_display('title', join=True), 481 track.get_tag_display('artist', join=True), 482 ] 483 length = max(-1, int(round(float(track.get_tag_raw('__length') or -1)))) 484 485 track_path = track.get_loc_for_io() 486 track_path = self.get_track_export_path(path, track_path, options) 487 488 pls_playlist.set('playlist', 'File%d' % position, track_path) 489 pls_playlist.set('playlist', 'Title%d' % position, ' - '.join(title)) 490 pls_playlist.set('playlist', 'Length%d' % position, length) 491 492 pls_playlist.set('playlist', 'Version', 2) 493 494 with GioFileOutputStream(Gio.File.new_for_uri(path)) as stream: 495 pls_playlist.write(stream) 496 497 def import_from_file(self, path): 498 """ 499 Import a playlist from a given path 500 501 :param path: the source path 502 :type path: string 503 :returns: the playlist 504 :rtype: :class:`Playlist` 505 """ 506 from configparser import ( 507 RawConfigParser, 508 MissingSectionHeaderError, 509 NoOptionError, 510 ) 511 512 pls_playlist = RawConfigParser() 513 gfile = Gio.File.new_for_uri(path) 514 515 logger.debug('Importing PLS playlist: %s', path) 516 517 try: 518 with GioFileInputStream(gfile) as stream: 519 pls_playlist.readfp(stream) 520 except MissingSectionHeaderError: 521 # Most likely version 1, thus only a list of URIs 522 playlist = Playlist(self.name_from_path(path)) 523 524 with GioFileInputStream(gfile) as stream: 525 for line in stream: 526 527 line = line.strip() 528 529 if not line: 530 continue 531 532 track = trax.Track(self.get_track_import_path(path, line)) 533 534 if track.get_tag_raw('title') is None: 535 track.set_tag_raw( 536 'title', common.sanitize_url(self.name_from_path(line)) 537 ) 538 539 playlist.append(track) 540 541 return playlist 542 543 if not pls_playlist.has_section('playlist'): 544 raise InvalidPlaylistTypeError(_('Invalid format for %s.') % self.title) 545 546 if not pls_playlist.has_option('playlist', 'version'): 547 logger.warning('No PLS version specified, ' 'assuming 2. [%s]', path) 548 pls_playlist.set('playlist', 'version', 2) 549 550 version = pls_playlist.getint('playlist', 'version') 551 552 if version != 2: 553 raise InvalidPlaylistTypeError( 554 _('Unsupported version %(version)s for %(type)s') 555 % {'version': version, 'type': self.title} 556 ) 557 558 if not pls_playlist.has_option('playlist', 'numberofentries'): 559 raise InvalidPlaylistTypeError(_('Invalid format for %s.') % self.title) 560 561 # PLS playlists store no name, thus retrieve from path 562 playlist = Playlist(common.sanitize_url(self.name_from_path(path))) 563 numberofentries = pls_playlist.getint('playlist', 'numberofentries') 564 565 for position in range(1, numberofentries + 1): 566 try: 567 uri = pls_playlist.get('playlist', 'file%d' % position) 568 except NoOptionError: 569 continue 570 571 track = trax.Track(self.get_track_import_path(path, uri)) 572 title = artist = None 573 length = 0 574 575 try: 576 title = pls_playlist.get('playlist', 'title%d' % position) 577 except NoOptionError: 578 title = common.sanitize_url(self.name_from_path(uri)) 579 else: 580 title = title.split(' - ', 1) 581 582 if len(title) > 1: # "Artist - Title" 583 artist, title = title 584 else: 585 title = title[0] 586 587 try: 588 length = pls_playlist.getint('playlist', 'length%d' % position) 589 except NoOptionError: 590 pass 591 592 if track.get_tag_raw('title') is None and title: 593 track.set_tag_raw('title', title) 594 595 if track.get_tag_raw('artist') is None and artist: 596 track.set_tag_raw('artist', artist) 597 598 if track.get_tag_raw('__length') is None: 599 track.set_tag_raw('__length', max(0, length)) 600 601 playlist.append(track) 602 603 return playlist 604 605 606providers.register('playlist-format-converter', PLSConverter()) 607 608 609class ASXConverter(FormatConverter): 610 """ 611 Import from and export to ASX format 612 """ 613 614 title = _('ASX Playlist') 615 content_types = [ 616 'video/x-ms-asf', 617 'audio/x-ms-asx', 618 'audio/x-ms-wax', 619 'video/x-ms-wvx', 620 ] 621 file_extensions = ['asx', 'wax', 'wvx'] 622 623 def __init__(self): 624 FormatConverter.__init__(self, 'asx') 625 626 def export_to_file(self, playlist, path, options=None): 627 """ 628 Export a playlist to a given path 629 630 :param playlist: the playlist 631 :type playlist: :class:`Playlist` 632 :param path: the target path 633 :type path: string 634 :param options: exporting options 635 :type options: :class:`PlaylistExportOptions` 636 """ 637 from xml.sax.saxutils import escape 638 639 with GioFileOutputStream(Gio.File.new_for_uri(path)) as stream: 640 stream.write('<asx version="3.0">\n') 641 stream.write(' <title>%s</title>\n' % escape(playlist.name)) 642 643 for track in playlist: 644 stream.write(' <entry>\n') 645 646 title = track.get_tag_raw('title', join=True) 647 artist = track.get_tag_raw('artist', join=True) 648 649 if title: 650 stream.write(' <title>%s</title>\n' % escape(title)) 651 652 if artist: 653 stream.write(' <author>%s</author>\n' % escape(artist)) 654 655 track_path = track.get_loc_for_io() 656 track_path = self.get_track_export_path(path, track_path, options) 657 658 stream.write(' <ref href="%s" />\n' % track_path) 659 stream.write(' </entry>\n') 660 661 stream.write('</asx>') 662 663 def import_from_file(self, path): 664 """ 665 Import a playlist from a given path 666 667 :param path: the source path 668 :type path: string 669 :returns: the playlist 670 :rtype: :class:`Playlist` 671 """ 672 from xml.etree.cElementTree import XMLParser 673 674 playlist = Playlist(self.name_from_path(path)) 675 676 logger.debug('Importing ASX playlist: %s', path) 677 678 with GioFileInputStream(Gio.File.new_for_uri(path)) as stream: 679 parser = XMLParser(target=self.ASXPlaylistParser()) 680 parser.feed(stream.read()) 681 682 try: 683 playlistdata = parser.close() 684 except Exception: 685 pass 686 else: 687 if playlistdata['name']: 688 playlist.name = playlistdata['name'] 689 690 for trackdata in playlistdata['tracks']: 691 track = trax.Track( 692 self.get_track_import_path(path, trackdata['uri']) 693 ) 694 695 ntags = {} 696 for tag, value in trackdata['tags'].items(): 697 if not track.get_tag_raw(tag) and value: 698 ntags[tag] = value 699 if ntags: 700 track.set_tags(**ntags) 701 702 playlist.append(track) 703 704 return playlist 705 706 class ASXPlaylistParser: 707 """ 708 Target for xml.etree.ElementTree.XMLParser, allows 709 for parsing ASX playlists case-insensitive 710 """ 711 712 def __init__(self): 713 self._stack = deque() 714 715 self._playlistdata = {'name': None, 'tracks': []} 716 self._trackuri = None 717 self._trackdata = {} 718 719 def start(self, tag, attributes): 720 """ 721 Checks the ASX version and stores 722 the URI of the current track 723 """ 724 depth = len(self._stack) 725 # Convert both tag and attributes to lowercase 726 tag = tag.lower() 727 attributes = {k.lower(): v for k, v in attributes.items()} 728 729 if depth > 0: 730 if depth == 2 and self._stack[-1] == 'entry' and tag == 'ref': 731 self._trackuri = attributes.get('href', None) 732 # Check root element and version 733 elif tag != 'asx' or attributes.get('version', None) != '3.0': 734 return 735 736 self._stack.append(tag) 737 738 def data(self, data): 739 """ 740 Stores track data and playlist name 741 """ 742 depth = len(self._stack) 743 744 if depth > 0 and data: 745 element = self._stack[-1] 746 747 if depth == 3: 748 # Only consider title and author for now 749 if element == 'title': 750 self._trackdata['title'] = data 751 elif element == 'author': 752 self._trackdata['artist'] = data 753 elif depth == 2 and element == 'title': 754 self._playlistdata['name'] = data 755 756 def end(self, tag): 757 """ 758 Appends track data 759 """ 760 try: 761 self._stack.pop() 762 except IndexError: # Invalid playlist 763 pass 764 else: 765 if tag.lower() == 'entry': 766 # Only add track data if we have at least an URI 767 if self._trackuri: 768 self._playlistdata['tracks'].append( 769 {'uri': self._trackuri, 'tags': self._trackdata.copy()} 770 ) 771 772 self._trackuri = None 773 self._trackdata.clear() 774 775 def close(self): 776 """ 777 Returns the playlist data including 778 data of all successfully read tracks 779 780 :rtype: dict 781 """ 782 return self._playlistdata 783 784 785providers.register('playlist-format-converter', ASXConverter()) 786 787 788class XSPFConverter(FormatConverter): 789 """ 790 Import from and export to XSPF format 791 """ 792 793 title = _('XSPF Playlist') 794 content_types = ['application/xspf+xml'] 795 796 def __init__(self): 797 FormatConverter.__init__(self, 'xspf') 798 799 # TODO: support image tag for CoverManager 800 self.tags = { 801 'title': 'title', 802 'creator': 'artist', 803 'album': 'album', 804 'trackNum': 'tracknumber', 805 } 806 807 def export_to_file(self, playlist, path, options=None): 808 """ 809 Export a playlist to a given path 810 811 :param playlist: the playlist 812 :type playlist: :class:`Playlist` 813 :param path: the target path 814 :type path: string 815 :param options: exporting options 816 :type options: :class:`PlaylistExportOptions` 817 """ 818 from xml.sax.saxutils import escape 819 820 with GioFileOutputStream(Gio.File.new_for_uri(path)) as stream: 821 stream.write('<?xml version="1.0" encoding="UTF-8"?>\n') 822 stream.write('<playlist version="1" xmlns="http://xspf.org/ns/0/">\n') 823 824 if playlist.name: 825 stream.write(' <title>%s</title>\n' % escape(playlist.name)) 826 827 stream.write(' <trackList>\n') 828 829 for track in playlist: 830 stream.write(' <track>\n') 831 for element, tag in self.tags.items(): 832 if not track.get_tag_raw(tag): 833 continue 834 stream.write( 835 ' <%s>%s</%s>\n' 836 % (element, escape(track.get_tag_raw(tag, join=True)), element) 837 ) 838 839 track_path = track.get_loc_for_io() 840 track_path = self.get_track_export_path(path, track_path, options) 841 842 stream.write(' <location>%s</location>\n' % escape(track_path)) 843 stream.write(' </track>\n') 844 845 stream.write(' </trackList>\n') 846 stream.write('</playlist>\n') 847 848 def import_from_file(self, path): 849 """ 850 Import a playlist from a given path 851 852 :param path: the source path 853 :type path: string 854 :returns: the playlist 855 :rtype: :class:`Playlist` 856 """ 857 # TODO: support content resolution 858 import xml.etree.cElementTree as ETree 859 860 playlist = Playlist(name=self.name_from_path(path)) 861 862 logger.debug('Importing XSPF playlist: %s', path) 863 864 with GioFileInputStream(Gio.File.new_for_uri(path)) as stream: 865 tree = ETree.ElementTree(file=stream) 866 ns = "{http://xspf.org/ns/0/}" 867 nodes = tree.find("%strackList" % ns).findall("%strack" % ns) 868 titlenode = tree.find("%stitle" % ns) 869 870 if titlenode is not None: 871 playlist.name = titlenode.text.strip() 872 873 for n in nodes: 874 location = n.find("%slocation" % ns).text.strip() 875 track = trax.Track(self.get_track_import_path(path, location)) 876 for element, tag in self.tags.items(): 877 try: 878 track.set_tag_raw( 879 tag, n.find("%s%s" % (ns, element)).text.strip() 880 ) 881 except Exception: 882 pass 883 playlist.append(track) 884 885 return playlist 886 887 888providers.register('playlist-format-converter', XSPFConverter()) 889 890 891class Playlist: 892 # TODO: how do we document events in sphinx? 893 """ 894 Basic class for handling a list of tracks 895 896 EVENTS: (all events are synchronous) 897 * playlist_tracks_added 898 * fired: after tracks are added 899 * data: list of tuples of (index, track) 900 * playlist_tracks_removed 901 * fired: after tracks are removed 902 * data: list of tuples of (index, track) 903 * playlist_current_position_changed 904 * playlist_shuffle_mode_changed 905 * playlist_random_mode_changed 906 * playlist_dynamic_mode_changed 907 """ 908 #: Valid shuffle modes (list of string) 909 shuffle_modes = ['disabled', 'track', 'album', 'random'] 910 #: Titles of the valid shuffle modes (list of string) 911 shuffle_mode_names = [ 912 _('Shuffle _Off'), 913 _('Shuffle _Tracks'), 914 _('Shuffle _Albums'), 915 _('_Random'), 916 ] 917 #: Valid repeat modes (list of string) 918 repeat_modes = ['disabled', 'all', 'track'] 919 #: Titles of the valid repeat modes (list of string) 920 repeat_mode_names = [_('Repeat _Off'), _('Repeat _All'), _('Repeat O_ne')] 921 #: Valid dynamic modes 922 dynamic_modes = ['disabled', 'enabled'] 923 #: Titles of the valid dynamic modes 924 dynamic_mode_names = [_('Dynamic _Off'), _('Dynamic by Similar _Artists')] 925 save_attrs = [ 926 'shuffle_mode', 927 'repeat_mode', 928 'dynamic_mode', 929 'current_position', 930 'name', 931 ] 932 __playlist_format_version = [2, 0] 933 934 def __init__(self, name, initial_tracks=[]): 935 """ 936 :param name: the initial name of the playlist 937 :type name: string 938 :param initial_tracks: the tracks which shall 939 populate the playlist initially 940 :type initial_tracks: list of :class:`xl.trax.Track` 941 """ 942 self.__tracks = MetadataList() 943 for track in initial_tracks: 944 if not isinstance(track, trax.Track): 945 raise ValueError("Need trax.Track object, got %r" % type(track)) 946 self.__tracks.append(track) 947 self.__shuffle_mode = self.shuffle_modes[0] 948 self.__repeat_mode = self.repeat_modes[0] 949 self.__dynamic_mode = self.dynamic_modes[0] 950 951 # dirty: any change that would alter the on-disk 952 # representation should set this 953 # needs_save: changes to list content should set this. 954 # Determines when the 'unsaved' indicator is shown to the user. 955 self.__dirty = False 956 self.__needs_save = False 957 self.__name = name 958 self.__next_data = None 959 self.__current_position = -1 960 self.__spat_position = -1 961 self.__shuffle_history_counter = 1 # start positive so we can 962 # just do an if directly on the value 963 event.add_callback(self.on_playback_track_start, "playback_track_start") 964 965 ### playlist-specific API ### 966 967 def _set_name(self, name): 968 self.__name = name 969 self.__needs_save = self.__dirty = True 970 event.log_event("playlist_name_changed", self, name) 971 972 #: The playlist name (string) 973 name = property(lambda self: self.__name, _set_name) 974 #: Whether the playlist was changed or not (boolean) 975 dirty = property(lambda self: self.__dirty) 976 977 def clear(self): 978 """ 979 Removes all contained tracks 980 """ 981 del self[:] 982 983 def get_current_position(self): 984 """ 985 Retrieves the current position within the playlist 986 987 :returns: the position 988 :rtype: int 989 """ 990 return self.__current_position 991 992 def set_current_position(self, position): 993 """ 994 Sets the current position within the playlist 995 996 :param position: the new position 997 :type position: int 998 """ 999 self.__next_data = None 1000 oldposition = self.__current_position 1001 if oldposition == position: 1002 return 1003 if position != -1: 1004 if position >= len(self.__tracks): 1005 raise IndexError("Cannot set position past end of playlist") 1006 self.__tracks.set_meta_key(position, "playlist_current_position", True) 1007 self.__current_position = position 1008 if oldposition != -1: 1009 try: 1010 self.__tracks.del_meta_key(oldposition, "playlist_current_position") 1011 except KeyError: 1012 pass 1013 self.__dirty = True 1014 event.log_event( 1015 "playlist_current_position_changed", self, (position, oldposition) 1016 ) 1017 1018 #: The position within the playlist (int) 1019 current_position = property(get_current_position, set_current_position) 1020 1021 def get_spat_position(self): 1022 """ 1023 Retrieves the current position within the playlist 1024 after which progressing shall be stopped 1025 1026 :returns: the position 1027 :rtype: int 1028 """ 1029 return self.__spat_position 1030 1031 def set_spat_position(self, position): 1032 """ 1033 Sets the current position within the playlist 1034 after which progressing shall be stopped 1035 1036 :param position: the new position 1037 :type position: int 1038 """ 1039 self.__next_data = None 1040 oldposition = self.spat_position 1041 self.__tracks.set_meta_key(position, "playlist_spat_position", True) 1042 self.__spat_position = position 1043 if oldposition != -1: 1044 try: 1045 self.__tracks.del_meta_key(oldposition, "playlist_spat_position") 1046 except KeyError: 1047 pass 1048 self.__dirty = True 1049 event.log_event("playlist_spat_position_changed", self, (position, oldposition)) 1050 1051 #: The position within the playlist after which to stop progressing (int) 1052 spat_position = property(get_spat_position, set_spat_position) 1053 1054 def get_current(self): 1055 """ 1056 Retrieves the track at the current position 1057 1058 :returns: the track 1059 :rtype: :class:`xl.trax.Track` or None 1060 """ 1061 if self.current_position == -1: 1062 return None 1063 return self.__tracks[self.current_position] 1064 1065 current = property(get_current) 1066 1067 def get_shuffle_history(self): 1068 """ 1069 Retrieves the history of played 1070 tracks from a shuffle run 1071 1072 :returns: the tracks 1073 :rtype: list 1074 """ 1075 return [ 1076 (i, self.__tracks[i]) 1077 for i in range(len(self)) 1078 if self.__tracks.get_meta_key(i, 'playlist_shuffle_history') 1079 ] 1080 1081 def clear_shuffle_history(self): 1082 """ 1083 Clear the history of played 1084 tracks from a shuffle run 1085 """ 1086 for i in range(len(self)): 1087 try: 1088 self.__tracks.del_meta_key(i, "playlist_shuffle_history") 1089 except Exception: 1090 pass 1091 1092 @common.threaded 1093 def __fetch_dynamic_tracks(self): 1094 dynamic.MANAGER.populate_playlist(self) 1095 1096 def __next_random_track(self, current_position, mode="track"): 1097 """ 1098 Returns a valid next track if shuffle is activated based 1099 on random_mode 1100 """ 1101 if mode == "album": 1102 # TODO: we really need proper album-level operations in 1103 # xl.trax for this 1104 try: 1105 # Try and get the next track on the album 1106 # NB If the user starts the playlist from the middle 1107 # of the album some tracks of the album remain off the 1108 # tracks_history, and the album can be selected again 1109 # randomly from its first track 1110 if current_position == -1: 1111 raise IndexError 1112 curr = self[current_position] 1113 t = [ 1114 x 1115 for i, x in enumerate(self) 1116 if x.get_tag_raw('album') == curr.get_tag_raw('album') 1117 and i > current_position 1118 ] 1119 t = trax.sort_tracks(['discnumber', 'tracknumber'], t) 1120 return self.__tracks.index(t[0]), t[0] 1121 1122 except IndexError: # Pick a new album 1123 hist = set(self.get_shuffle_history()) 1124 albums = set() 1125 for i, x in enumerate(self): 1126 if (i, x) in hist: 1127 continue 1128 alb = x.get_tag_raw('album') 1129 if alb: 1130 albums.add(tuple(alb)) 1131 if not albums: 1132 return -1, None 1133 album = list(random.choice(list(albums))) 1134 t = [x for x in self if x.get_tag_raw('album') == album] 1135 t = trax.sort_tracks(['tracknumber'], t) 1136 return self.__tracks.index(t[0]), t[0] 1137 elif mode == 'random': 1138 try: 1139 return random.choice( 1140 [(i, self.__tracks[i]) for i, tr in enumerate(self.__tracks)] 1141 ) 1142 except IndexError: 1143 return -1, None 1144 else: 1145 hist = {i for i, tr in self.get_shuffle_history()} 1146 try: 1147 return random.choice( 1148 [ 1149 (i, self.__tracks[i]) 1150 for i, tr in enumerate(self.__tracks) 1151 if i not in hist 1152 ] 1153 ) 1154 except IndexError: # no more tracks 1155 return -1, None 1156 1157 def __get_next(self, current_position): 1158 1159 # don't recalculate 1160 if self.__next_data is not None: 1161 return self.__next_data[2] 1162 1163 repeat_mode = self.repeat_mode 1164 shuffle_mode = self.shuffle_mode 1165 if current_position == self.spat_position and current_position != -1: 1166 self.__next_data = (-1, None, None) 1167 return None 1168 1169 if repeat_mode == 'track': 1170 self.__next_data = (None, None, self.current) 1171 return self.current 1172 1173 next_index = -1 1174 1175 if shuffle_mode != 'disabled': 1176 if current_position != -1: 1177 self.__tracks.set_meta_key( 1178 current_position, 1179 "playlist_shuffle_history", 1180 self.__shuffle_history_counter, 1181 ) 1182 self.__shuffle_history_counter += 1 1183 next_index, next = self.__next_random_track(current_position, shuffle_mode) 1184 if next is not None: 1185 self.__next_data = (None, next_index) 1186 else: 1187 self.clear_shuffle_history() 1188 else: 1189 try: 1190 next = self[current_position + 1] 1191 next_index = current_position + 1 1192 except IndexError: 1193 next = None 1194 1195 if next is None: 1196 if repeat_mode == 'all': 1197 if len(self) == 1: 1198 next = self[current_position] 1199 next_index = current_position 1200 if len(self) > 1: 1201 return self.__get_next(-1) 1202 1203 self.__next_data = (None, next_index, next) 1204 return next 1205 1206 def get_next(self): 1207 """ 1208 Retrieves the next track that will be played. Does not 1209 actually set the position. When you call next(), it should 1210 return the same track, even in random shuffle modes. 1211 1212 This exists to support retrieving a track before it actually 1213 needs to be played, such as for pre-buffering. 1214 1215 :returns: the next track to be played 1216 :rtype: :class:`xl.trax.Track` or None 1217 """ 1218 return self.__get_next(self.current_position) 1219 1220 def next(self): 1221 """ 1222 Progresses to the next track within the playlist 1223 and takes shuffle and repeat modes into account 1224 1225 :returns: the new current track 1226 :rtype: :class:`xl.trax.Track` or None 1227 """ 1228 1229 if not self.__next_data: 1230 self.__get_next(self.current_position) 1231 1232 spat, index, next = self.__next_data 1233 1234 if spat is not None: 1235 self.spat_position = -1 1236 return None 1237 elif index is not None: 1238 try: 1239 self.current_position = index 1240 except IndexError: 1241 self.current_position = -1 1242 else: 1243 self.__next_data = None 1244 1245 return self.current 1246 1247 def prev(self): 1248 """ 1249 Progresses to the previous track within the playlist 1250 and takes shuffle and repeat modes into account 1251 1252 :returns: the new current track 1253 :rtype: :class:`xl.trax.Track` or None 1254 """ 1255 repeat_mode = self.repeat_mode 1256 shuffle_mode = self.shuffle_mode 1257 if repeat_mode == 'track': 1258 return self.current 1259 1260 if shuffle_mode != 'disabled': 1261 shuffle_hist, prev_index = max( 1262 (self.__tracks.get_meta_key(i, 'playlist_shuffle_history', 0), i) 1263 for i in range(len(self)) 1264 ) 1265 1266 if shuffle_hist: 1267 self.current_position = prev_index 1268 self.__tracks.del_meta_key(prev_index, 'playlist_shuffle_history') 1269 else: 1270 position = self.current_position - 1 1271 if position < 0: 1272 if repeat_mode == 'all': 1273 position = len(self) - 1 1274 else: 1275 position = 0 if len(self) else -1 1276 self.current_position = position 1277 return self.get_current() 1278 1279 ### track advance modes ### 1280 # This code may look a little overkill, but it's this way to 1281 # maximize forwards-compatibility. get_ methods will not overwrite 1282 # currently-set modes which may be from a future version, while set_ 1283 # methods explicitly disallow modes not supported in this version. 1284 # This ensures that 1) saved modes are never clobbered unless a 1285 # known mode is to be set, and 2) the values returned in _mode will 1286 # always be supported in the running version. 1287 1288 def __get_mode(self, modename): 1289 mode = getattr(self, "_Playlist__%s_mode" % modename) 1290 modes = getattr(self, "%s_modes" % modename) 1291 if mode in modes: 1292 return mode 1293 else: 1294 return modes[0] 1295 1296 def __set_mode(self, modename, mode): 1297 modes = getattr(self, "%s_modes" % modename) 1298 if mode not in modes: 1299 raise TypeError("Mode %s is invalid" % mode) 1300 else: 1301 self.__dirty = True 1302 setattr(self, "_Playlist__%s_mode" % modename, mode) 1303 event.log_event("playlist_%s_mode_changed" % modename, self, mode) 1304 1305 def get_shuffle_mode(self): 1306 """ 1307 Retrieves the current shuffle mode 1308 1309 :returns: the shuffle mode 1310 :rtype: string 1311 """ 1312 return self.__get_mode("shuffle") 1313 1314 def set_shuffle_mode(self, mode): 1315 """ 1316 Sets the current shuffle mode 1317 1318 :param mode: the new shuffle mode 1319 :type mode: string 1320 """ 1321 self.__set_mode("shuffle", mode) 1322 if mode == 'disabled': 1323 self.clear_shuffle_history() 1324 1325 #: The current shuffle mode (string) 1326 shuffle_mode = property(get_shuffle_mode, set_shuffle_mode) 1327 1328 def get_repeat_mode(self): 1329 """ 1330 Retrieves the current repeat mode 1331 1332 :returns: the repeat mode 1333 :rtype: string 1334 """ 1335 return self.__get_mode('repeat') 1336 1337 def set_repeat_mode(self, mode): 1338 """ 1339 Sets the current repeat mode 1340 1341 :param mode: the new repeat mode 1342 :type mode: string 1343 """ 1344 self.__set_mode("repeat", mode) 1345 1346 #: The current repeat mode (string) 1347 repeat_mode = property(get_repeat_mode, set_repeat_mode) 1348 1349 def get_dynamic_mode(self): 1350 """ 1351 Retrieves the current dynamic mode 1352 1353 :returns: the dynamic mode 1354 :rtype: string 1355 """ 1356 return self.__get_mode("dynamic") 1357 1358 def set_dynamic_mode(self, mode): 1359 """ 1360 Sets the current dynamic mode 1361 1362 :param mode: the new dynamic mode 1363 :type mode: string 1364 """ 1365 self.__set_mode("dynamic", mode) 1366 1367 #: The current dynamic mode (string) 1368 dynamic_mode = property(get_dynamic_mode, set_dynamic_mode) 1369 1370 def randomize(self, positions=None): 1371 """ 1372 Randomizes the content of the playlist contrary to 1373 shuffle which affects only the progressing order 1374 1375 By default all tracks in the playlist are randomized, 1376 but a list of positions can be passed. The tracks on 1377 these positions will be randomized, all other tracks 1378 will keep their positions. 1379 1380 :param positions: list of track positions to randomize 1381 :type positions: iterable 1382 """ 1383 # Turn 2 lists into a list of tuples 1384 tracks = list(zip(self.__tracks, self.__tracks.metadata)) 1385 1386 if positions: 1387 # For 2 items, simple swapping is most reasonable 1388 if len(positions) == 2: 1389 tracks[positions[0]], tracks[positions[1]] = ( 1390 tracks[positions[1]], 1391 tracks[positions[0]], 1392 ) 1393 else: 1394 # Extract items and shuffle them 1395 shuffle_tracks = [t for i, t in enumerate(tracks) if i in positions] 1396 random.shuffle(shuffle_tracks) 1397 1398 # Put shuffled items back 1399 for position in positions: 1400 tracks[position] = shuffle_tracks.pop() 1401 else: 1402 random.shuffle(tracks) 1403 1404 # Turn list of tuples into 2 tuples 1405 self[:] = MetadataList(*zip(*tracks)) 1406 1407 def sort(self, tags, reverse=False): 1408 """ 1409 Sorts the content of the playlist 1410 1411 :param tags: tags to sort by 1412 :type tags: list of strings 1413 :param reverse: whether the sorting shall be reversed 1414 :type reverse: boolean 1415 """ 1416 data = zip(self.__tracks, self.__tracks.metadata) 1417 data = trax.sort_tracks( 1418 tags, data, trackfunc=operator.itemgetter(0), reverse=reverse 1419 ) 1420 l = MetadataList() 1421 l.extend([x[0] for x in data]) 1422 l.metadata = [x[1] for x in data] 1423 self[:] = l 1424 1425 # TODO[0.4?]: drop our custom disk playlist format in favor of an 1426 # extended XSPF playlist (using xml namespaces?). 1427 1428 # TODO: add timeout saving support. 5-10 seconds after last change, 1429 # perhaps? 1430 1431 def save_to_location(self, location): 1432 """ 1433 Writes the content of the playlist to a given location 1434 1435 :param location: the location to save to 1436 :type location: string 1437 """ 1438 new_location = location + ".new" 1439 1440 with open(new_location, 'w') as f: 1441 for track in self.__tracks: 1442 loc = track.get_loc_for_io() 1443 meta = {} 1444 for tag in ('artist', 'album', 'tracknumber', 'title', 'genre', 'date'): 1445 value = track.get_tag_raw(tag, join=True) 1446 if value is not None: 1447 meta[tag] = value 1448 meta = urllib.parse.urlencode(meta) 1449 print(loc, meta, sep='\t', file=f) 1450 1451 print('EOF', file=f) 1452 1453 for attr in self.save_attrs: 1454 val = getattr(self, attr) 1455 try: 1456 configstr = settings.MANAGER._val_to_str(val) 1457 except ValueError: 1458 configstr = '' 1459 print('%s=%s' % (attr, configstr), file=f) 1460 1461 os.replace(new_location, location) 1462 1463 self.__needs_save = self.__dirty = False 1464 1465 def load_from_location(self, location): 1466 """ 1467 Loads the content of the playlist from a given location 1468 1469 :param location: the location to load from 1470 :type location: string 1471 """ 1472 # note - this is not guaranteed to fire events when it sets 1473 # attributes. It is intended ONLY for initial setup, not for 1474 # reloading a playlist inline. 1475 f = None 1476 for loc in [location, location + ".new"]: 1477 try: 1478 f = open(loc, 'r') 1479 break 1480 except Exception: 1481 pass 1482 if not f: 1483 return 1484 locs = [] 1485 while True: 1486 line = f.readline() 1487 if line == "EOF\n" or line == "": 1488 break 1489 locs.append(line.strip()) 1490 items = {} 1491 while True: 1492 line = f.readline() 1493 if line == "": 1494 break 1495 1496 try: 1497 item, strn = line[:-1].split("=", 1) 1498 except ValueError: 1499 continue # Skip erroneous lines 1500 1501 val = settings.MANAGER._str_to_val(strn) 1502 items[item] = val 1503 1504 ver = items.get("__playlist_format_version", [1]) 1505 if ver[0] == 1: 1506 if items.get("repeat_mode") == "playlist": 1507 items['repeat_mode'] = "all" 1508 elif ver[0] > self.__playlist_format_version[0]: 1509 raise IOError("Cannot load playlist, unknown format") 1510 elif ver > self.__playlist_format_version: 1511 logger.warning( 1512 "Playlist created on a newer Exaile version, some attributes may not be handled." 1513 ) 1514 f.close() 1515 1516 trs = [] 1517 1518 for loc in locs: 1519 meta = None 1520 if loc.find('\t') > -1: 1521 splitted = loc.split('\t') 1522 loc = "\t".join(splitted[:-1]) 1523 meta = splitted[-1] 1524 1525 track = None 1526 track = trax.Track(uri=loc) 1527 1528 # readd meta 1529 if not track: 1530 continue 1531 if not track.is_local() and meta is not None: 1532 meta = urllib.parse.parse_qs(meta) 1533 for k, v in meta.items(): 1534 track.set_tag_raw(k, v[0], notify_changed=False) 1535 1536 trs.append(track) 1537 1538 self.__tracks[:] = trs 1539 1540 for item, val in items.items(): 1541 if item in self.save_attrs: 1542 try: 1543 setattr(self, item, val) 1544 except TypeError: # don't bail if we try to set an invalid mode 1545 logger.debug( 1546 "Got a TypeError when trying to set attribute %s to %s during playlist restore.", 1547 item, 1548 val, 1549 ) 1550 1551 def reverse(self): 1552 # reverses current view 1553 pass 1554 1555 ### list-like API methods ### 1556 # parts of this section are taken from 1557 # https://code.activestate.com/recipes/440656-list-mixin/ 1558 1559 def __len__(self): 1560 return len(self.__tracks) 1561 1562 def __contains__(self, track): 1563 return track in self.__tracks 1564 1565 def __tuple_from_slice(self, i): 1566 """ 1567 Get (start, end, step) tuple from slice object. 1568 """ 1569 (start, end, step) = i.indices(len(self)) 1570 if i.step is None: 1571 step = 1 1572 return (start, end, step) 1573 1574 def __adjust_current_pos(self, oldpos, removed, added): 1575 newpos = oldpos 1576 for i, tr in removed: 1577 if i <= oldpos: 1578 newpos -= 1 1579 for i, tr in added: 1580 if i <= newpos: 1581 newpos += 1 1582 self.current_position = newpos 1583 1584 def __getitem__(self, i): 1585 return self.__tracks.__getitem__(i) 1586 1587 def __setitem__(self, i, value): 1588 oldtracks = self.__getitem__(i) 1589 removed = MetadataList() 1590 added = MetadataList() 1591 oldpos = self.current_position 1592 1593 if isinstance(i, slice): 1594 for x in value: 1595 if not isinstance(x, trax.Track): 1596 raise ValueError("Need trax.Track object, got %r" % type(x)) 1597 1598 (start, end, step) = self.__tuple_from_slice(i) 1599 1600 if isinstance(value, MetadataList): 1601 metadata = value.metadata 1602 else: 1603 metadata = [None] * len(value) 1604 1605 if step != 1: 1606 if len(value) != len(oldtracks): 1607 raise ValueError("Extended slice assignment must match sizes.") 1608 self.__tracks.__setitem__(i, value) 1609 removed = MetadataList( 1610 zip(range(start, end, step), oldtracks), oldtracks.metadata 1611 ) 1612 if step == 1: 1613 end = start + len(value) 1614 1615 added = MetadataList(zip(range(start, end, step), value), metadata) 1616 else: 1617 if not isinstance(value, trax.Track): 1618 raise ValueError("Need trax.Track object, got %r" % type(value)) 1619 self.__tracks[i] = value 1620 removed = [(i, oldtracks)] 1621 added = [(i, value)] 1622 1623 self.on_tracks_changed() 1624 1625 if removed: 1626 event.log_event('playlist_tracks_removed', self, removed) 1627 if added: 1628 event.log_event('playlist_tracks_added', self, added) 1629 self.__adjust_current_pos(oldpos, removed, added) 1630 1631 self.__needs_save = self.__dirty = True 1632 1633 def __delitem__(self, i): 1634 if isinstance(i, slice): 1635 (start, end, step) = self.__tuple_from_slice(i) 1636 oldtracks = self.__getitem__(i) 1637 oldpos = self.current_position 1638 self.__tracks.__delitem__(i) 1639 removed = MetadataList() 1640 1641 if isinstance(i, slice): 1642 removed = MetadataList( 1643 zip(range(start, end, step), oldtracks), oldtracks.metadata 1644 ) 1645 else: 1646 removed = [(i, oldtracks)] 1647 1648 self.on_tracks_changed() 1649 event.log_event('playlist_tracks_removed', self, removed) 1650 self.__adjust_current_pos(oldpos, removed, []) 1651 self.__needs_save = self.__dirty = True 1652 1653 def append(self, other): 1654 """ 1655 Appends a single track to the playlist 1656 1657 Prefer extend() for batch updates, so that 1658 playlist_tracks_added is not emitted excessively. 1659 1660 :param other: a :class:`xl.trax.Track` 1661 """ 1662 self[len(self) : len(self)] = [other] 1663 1664 def extend(self, other): 1665 """ 1666 Extends the playlist by another playlist 1667 1668 :param other: list of :class:`xl.trax.Track` 1669 """ 1670 self[len(self) : len(self)] = other 1671 1672 def count(self, other): 1673 """ 1674 Returns the count of contained tracks 1675 1676 :returns: the count 1677 :rtype: int 1678 """ 1679 return self.__tracks.count(other) 1680 1681 def index(self, item, start=0, end=None): 1682 """ 1683 Retrieves the index of a track within the playlist 1684 1685 :returns: the index 1686 :rtype: int 1687 """ 1688 if end is None: 1689 return self.__tracks.index(item, start) 1690 else: 1691 return self.__tracks.index(item, start, end) 1692 1693 def pop(self, i=-1): 1694 """ 1695 Pops a track from the playlist 1696 1697 :param i: the index 1698 :type i: int 1699 :returns: the track 1700 :rtype: :class:`xl.trax.Track` 1701 """ 1702 item = self[i] 1703 del self[i] 1704 return item 1705 1706 def on_playback_track_start(self, event_type, player, track): 1707 1708 if player.queue is not None and player.queue.current_playlist == self: 1709 if self.dynamic_mode != 'disabled': 1710 self.__fetch_dynamic_tracks() 1711 1712 def on_tracks_changed(self, *args): 1713 for idx in range(len(self.__tracks)): 1714 if self.__tracks.get_meta_key(idx, "playlist_current_position"): 1715 self.__current_position = idx 1716 break 1717 else: 1718 self.__current_position = -1 1719 for idx in range(len(self.__tracks)): 1720 if self.__tracks.get_meta_key(idx, "playlist_spat_position"): 1721 self.__spat_position = idx 1722 break 1723 else: 1724 self.__spat_position = -1 1725 1726 1727class SmartPlaylist: 1728 """ 1729 Represents a Smart Playlist. 1730 This will query a collection object using a set of parameters 1731 1732 Simple usage: 1733 1734 >>> import xl.collection 1735 >>> col = xl.collection.Collection("Test Collection") 1736 >>> col.add_library(xl.collection.Library("./tests/data")) 1737 >>> col.rescan_libraries() 1738 >>> sp = SmartPlaylist(collection=col) 1739 >>> sp.add_param("artist", "==", "Delerium") 1740 >>> p = sp.get_playlist() 1741 >>> p[1]['album'][0] 1742 'Chimera' 1743 >>> 1744 """ 1745 1746 def __init__(self, name="", collection=None): 1747 """ 1748 Sets up a smart playlist 1749 1750 @param collection: a reference to a TrackDB object. 1751 """ 1752 self.search_params = [] 1753 self.custom_params = [] 1754 self.collection = collection 1755 self.or_match = False 1756 self.track_count = -1 1757 self.random_sort = False 1758 self.name = name 1759 self.sort_tags = None 1760 self.sort_order = None 1761 1762 def set_location(self, location): 1763 pass 1764 1765 def get_name(self): 1766 return self.name 1767 1768 def set_name(self, name): 1769 self.name = name 1770 1771 def set_collection(self, collection): 1772 """ 1773 change the collection backing this playlist 1774 1775 collection: the collection to use [Collection] 1776 """ 1777 self.collection = collection 1778 1779 def set_random_sort(self, sort): 1780 """ 1781 If True, the tracks added during update() will be randomized 1782 1783 @param sort: bool 1784 """ 1785 self.random_sort = sort 1786 self._dirty = True 1787 1788 def get_random_sort(self): 1789 """ 1790 Returns True if this playlist will randomly be sorted 1791 """ 1792 return self.random_sort 1793 1794 def set_return_limit(self, count): 1795 """ 1796 Sets the max number of tracks to return. 1797 1798 @param count: number of tracks to return. Set to -1 to return 1799 all matched 1800 """ 1801 self.track_count = count 1802 self._dirty = True 1803 1804 def get_return_limit(self): 1805 """ 1806 Returns the track count setting 1807 """ 1808 return self.track_count 1809 1810 def set_sort_tags(self, tags, reverse): 1811 """ 1812 Control playlist sorting 1813 1814 :param tags: List of tags to sort by 1815 :param reverse: Reverse the tracks after sorting 1816 """ 1817 self.sort_tags = tags 1818 self.sort_order = reverse 1819 1820 def get_sort_tags(self): 1821 """ 1822 :returns: (list of tags, reverse) 1823 """ 1824 return self.sort_tags, self.sort_order 1825 1826 def set_or_match(self, value): 1827 """ 1828 Set to True to make this an or match: match any of the 1829 parameters 1830 1831 value: True to match any, False to match all params 1832 """ 1833 self.or_match = value 1834 self._dirty = True 1835 1836 def get_or_match(self): 1837 """ 1838 Return if this is an any or and playlist 1839 """ 1840 return self.or_match 1841 1842 def add_param(self, field, op, value, index=-1): 1843 """ 1844 Adds a search parameter. 1845 1846 @param field: The field to operate on. [string] 1847 @param op: The operator. Valid operators are: 1848 >,<,>=,<=,=,!=,==,!==,>< (between) [string] 1849 @param value: The value to match against [string] 1850 @param index: Where to insert the parameter in the search 1851 order. -1 to append [int] 1852 """ 1853 if index: 1854 self.search_params.insert(index, [field, op, value]) 1855 else: 1856 self.search_params.append([field, op, value]) 1857 self._dirty = True 1858 1859 def set_custom_param(self, param, index=-1): 1860 """ 1861 Adds an arbitrary search parameter, exposing the full power 1862 of the new search system to the user. 1863 1864 param: the search query to use. [string] 1865 index: the index to insert at. default is append [int] 1866 """ 1867 if index: 1868 self.search_params.insert(index, param) 1869 else: 1870 self.search_params.append(param) 1871 self._dirty = True 1872 1873 def remove_param(self, index): 1874 """ 1875 Removes a parameter at the speficied index 1876 1877 index: the index of the parameter to remove 1878 """ 1879 self._dirty = True 1880 return self.search_params.pop(index) 1881 1882 def get_playlist(self, collection=None): 1883 """ 1884 Generates a playlist by querying the collection 1885 1886 @param collection: the collection to search (leave None to 1887 search internal ref) 1888 """ 1889 pl = Playlist(name=self.name) 1890 if not collection: 1891 collection = self.collection 1892 if not collection: # if there wasnt one set we might not have one 1893 return pl 1894 1895 search_string, matchers = self._create_search_data(collection) 1896 1897 matcher = trax.TracksMatcher(search_string, case_sensitive=False) 1898 1899 # prepend for now, since it is likely to remove more tracks, and 1900 # smart playlists don't support mixed and/or expressions yet 1901 for m in matchers: 1902 matcher.prepend_matcher(m, self.or_match) 1903 1904 trs = [t.track for t in trax.search_tracks(collection, [matcher])] 1905 if self.random_sort: 1906 random.shuffle(trs) 1907 else: 1908 order = False 1909 if self.sort_tags: 1910 order = self.sort_order 1911 sort_by = [self.sort_tags] + list(common.BASE_SORT_TAGS) 1912 else: 1913 sort_by = common.BASE_SORT_TAGS 1914 trs = trax.sort_tracks(sort_by, trs, reverse=order) 1915 if self.track_count > 0 and len(trs) > self.track_count: 1916 trs = trs[: self.track_count] 1917 1918 pl.extend(trs) 1919 1920 return pl 1921 1922 def _create_search_data(self, collection): 1923 """ 1924 Creates a search string + matchers based on the internal params 1925 """ 1926 1927 params = [] # parameter list 1928 matchers = [] # matchers list 1929 maximum = settings.get_option('rating/maximum', 5) 1930 durations = { 1931 'seconds': lambda value: timedelta(seconds=value), 1932 'minutes': lambda value: timedelta(minutes=value), 1933 'hours': lambda value: timedelta(hours=value), 1934 'days': lambda value: timedelta(days=value), 1935 'weeks': lambda value: timedelta(weeks=value), 1936 } 1937 1938 for param in self.search_params: 1939 if isinstance(param, str): 1940 params += [param] 1941 continue 1942 (field, op, value) = param 1943 fieldtype = tag_data.get(field) 1944 if fieldtype is not None: 1945 fieldtype = fieldtype.type 1946 1947 s = "" 1948 1949 if field == '__rating': 1950 value = 100.0 * value / maximum 1951 elif field == '__playlist': 1952 try: 1953 pl = main.exaile().playlists.get_playlist(value) 1954 except Exception: 1955 try: 1956 pl = ( 1957 main.exaile() 1958 .smart_playlists.get_playlist(value) 1959 .get_playlist(collection) 1960 ) 1961 except Exception as e: 1962 raise ValueError("Loading %s: %s" % (self.name, e)) 1963 1964 if op == 'pin': 1965 matchers.append(trax.TracksInList(pl)) 1966 else: 1967 matchers.append(trax.TracksNotInList(pl)) 1968 continue 1969 elif fieldtype == 'timestamp': 1970 duration, unit = value 1971 delta = durations[unit](duration) 1972 point = datetime.now() - delta 1973 value = time.mktime(point.timetuple()) 1974 1975 # fmt: off 1976 if op == ">=" or op == "<=": 1977 s += '( %(field)s%(op)s%(value)s ' \ 1978 '| %(field)s==%(value)s )' % \ 1979 { 1980 'field': field, 1981 'value': value, 1982 'op': op[0] 1983 } 1984 elif op == "!=" or op == "!==" or op == "!~": 1985 s += '! %(field)s%(op)s"%(value)s"' % \ 1986 { 1987 'field': field, 1988 'value': value, 1989 'op': op[1:] 1990 } 1991 elif op == "><": 1992 s += '( %(field)s>%(value1)s ' \ 1993 '%(field)s<%(value2)s )' % \ 1994 { 1995 'field': field, 1996 'value1': value[0], 1997 'value2': value[1] 1998 } 1999 elif op == '<!==>': # NOT NULL 2000 s += '! %(field)s=="__null__"' % \ 2001 { 2002 'field': field 2003 } 2004 elif op == '<==>': # IS NULL 2005 s += '%(field)s=="__null__"' % \ 2006 { 2007 'field': field 2008 } 2009 elif op == 'w=': # contains word 2010 s += '%(field)s~"\\b%(value)s\\b"' % \ 2011 { 2012 'field': field, 2013 'value': re.escape(value), 2014 } 2015 elif op == '!w=': # does not contain word 2016 s += '! %(field)s~"\\b%(value)s\\b"' % \ 2017 { 2018 'field': field, 2019 'value': re.escape(value), 2020 } 2021 else: 2022 s += '%(field)s%(op)s"%(value)s"' % \ 2023 { 2024 'field': field, 2025 'value': value, 2026 'op': op 2027 } 2028 2029 # fmt: on 2030 2031 params.append(s) 2032 2033 if self.or_match: 2034 return ' | '.join(params), matchers 2035 else: 2036 return ' '.join(params), matchers 2037 2038 def save_to_location(self, location): 2039 pdata = {} 2040 for item in [ 2041 'search_params', 2042 'custom_params', 2043 'or_match', 2044 'track_count', 2045 'random_sort', 2046 'name', 2047 'sort_tags', 2048 'sort_order', 2049 ]: 2050 pdata[item] = getattr(self, item) 2051 with open(location, 'wb') as fp: 2052 pickle.dump(pdata, fp) 2053 2054 def load_from_location(self, location): 2055 try: 2056 with open(location, 'rb') as fp: 2057 pdata = pickle.load(fp) 2058 except Exception: 2059 return 2060 for item in pdata: 2061 if hasattr(self, item): 2062 setattr(self, item, pdata[item]) 2063 2064 2065class PlaylistManager: 2066 """ 2067 Manages saving and loading of playlists 2068 """ 2069 2070 def __init__(self, playlist_dir='playlists', playlist_class=Playlist): 2071 """ 2072 Initializes the playlist manager 2073 2074 @param playlist_dir: the data dir to save playlists to 2075 @param playlist_class: the playlist class to use 2076 """ 2077 self.playlist_class = playlist_class 2078 self.playlist_dir = os.path.join(xdg.get_data_dirs()[0], playlist_dir) 2079 if not os.path.exists(self.playlist_dir): 2080 os.makedirs(self.playlist_dir) 2081 self.order_file = os.path.join(self.playlist_dir, 'order_file') 2082 self.playlists = [] 2083 self.load_names() 2084 2085 def _create_playlist(self, name): 2086 return self.playlist_class(name=name) 2087 2088 def has_playlist_name(self, playlist_name): 2089 """ 2090 Returns true if the manager has a playlist with the same name 2091 """ 2092 return playlist_name in self.playlists 2093 2094 def save_playlist(self, pl, overwrite=False): 2095 """ 2096 Saves a playlist 2097 2098 @param pl: the playlist 2099 @param overwrite: Set to [True] if you wish to overwrite a 2100 playlist should it happen to already exist 2101 """ 2102 name = pl.name 2103 if overwrite or name not in self.playlists: 2104 pl.save_to_location(os.path.join(self.playlist_dir, encode_filename(name))) 2105 2106 if name not in self.playlists: 2107 self.playlists.append(name) 2108 # self.playlists.sort() 2109 self.save_order() 2110 else: 2111 raise PlaylistExists 2112 2113 event.log_event('playlist_added', self, name) 2114 2115 def remove_playlist(self, name): 2116 """ 2117 Removes a playlist from the manager, also 2118 physically deletes its 2119 2120 @param name: the name of the playlist to remove 2121 """ 2122 if name in self.playlists: 2123 try: 2124 os.remove(os.path.join(self.playlist_dir, encode_filename(name))) 2125 except OSError: 2126 pass 2127 self.playlists.remove(name) 2128 event.log_event('playlist_removed', self, name) 2129 2130 def rename_playlist(self, playlist, new_name): 2131 """ 2132 Renames the playlist to new_name 2133 """ 2134 old_name = playlist.name 2135 if old_name in self.playlists: 2136 self.remove_playlist(old_name) 2137 playlist.name = new_name 2138 self.save_playlist(playlist) 2139 2140 def load_names(self): 2141 """ 2142 Loads the names of the playlists from the order file 2143 """ 2144 # collect the names of all playlists in playlist_dir 2145 existing = [] 2146 for f in os.listdir(self.playlist_dir): 2147 # everything except the order file shold be a playlist, but 2148 # check against hidden files since some editors put 2149 # temporary stuff in the same dir. 2150 if f != os.path.basename(self.order_file) and not f.startswith("."): 2151 pl = self._create_playlist(f) 2152 path = os.path.join(self.playlist_dir, f) 2153 try: 2154 pl.load_from_location(path) 2155 except Exception: 2156 logger.exception("Failed loading playlist: %r", path) 2157 else: 2158 existing.append(pl.name) 2159 2160 # if order_file exists then use it 2161 if os.path.isfile(self.order_file): 2162 ordered_playlists = self.load_from_location(self.order_file) 2163 self.playlists = [n for n in ordered_playlists if n in existing] 2164 else: 2165 self.playlists = existing 2166 2167 def get_playlist(self, name): 2168 """ 2169 Gets a playlist by name 2170 2171 @param name: the name of the playlist you wish to retrieve 2172 """ 2173 if name in self.playlists: 2174 pl = self._create_playlist(name) 2175 pl.load_from_location( 2176 os.path.join(self.playlist_dir, encode_filename(name)) 2177 ) 2178 return pl 2179 else: 2180 raise ValueError("No such playlist '%s'" % name) 2181 2182 def list_playlists(self): 2183 """ 2184 Returns all the contained playlist names 2185 """ 2186 return self.playlists[:] 2187 2188 def move(self, playlist, position, after=True): 2189 """ 2190 Moves the playlist to where position is 2191 """ 2192 # Remove the playlist first 2193 playlist_index = self.playlists.index(playlist) 2194 self.playlists.pop(playlist_index) 2195 # insert it now after position 2196 position_index = self.playlists.index(position) 2197 if after: 2198 position_index = position_index + 1 2199 self.playlists.insert(position_index, playlist) 2200 2201 def save_order(self): 2202 """ 2203 Saves the order to the order file 2204 """ 2205 self.save_to_location(self.order_file) 2206 2207 def save_to_location(self, location): 2208 """ 2209 Saves the names of the playlist to a file that is 2210 used to restore their order 2211 """ 2212 if os.path.exists(location): 2213 f = open(location + ".new", "w") 2214 else: 2215 f = open(location, "w") 2216 for playlist in self.playlists: 2217 f.write(playlist) 2218 f.write('\n') 2219 2220 f.write("EOF\n") 2221 f.close() 2222 if os.path.exists(location + ".new"): 2223 os.remove(location) 2224 os.rename(location + ".new", location) 2225 2226 def load_from_location(self, location): 2227 """ 2228 Loads the names of the playlist from a file. 2229 Their load order is their view order 2230 2231 @return: a list of the playlist names 2232 """ 2233 f = None 2234 for loc in [location, location + ".new"]: 2235 try: 2236 f = open(loc, 'r') 2237 break 2238 except Exception: 2239 pass 2240 if f is None: 2241 return [] 2242 playlists = [] 2243 while True: 2244 line = f.readline() 2245 if line == "EOF\n" or line == "": 2246 break 2247 playlists.append(line[:-1]) 2248 f.close() 2249 return playlists 2250 2251 2252class SmartPlaylistManager(PlaylistManager): 2253 """ 2254 Manages saving and loading of smart playlists 2255 """ 2256 2257 def __init__(self, playlist_dir, playlist_class=SmartPlaylist, collection=None): 2258 """ 2259 Initializes a smart playlist manager 2260 2261 @param playlist_dir: the data dir to save playlists to 2262 @param playlist_class: the playlist class to use 2263 @param collection: the default collection to use for searching 2264 """ 2265 self.collection = collection 2266 PlaylistManager.__init__( 2267 self, playlist_dir=playlist_dir, playlist_class=playlist_class 2268 ) 2269 2270 def _create_playlist(self, name): 2271 # set a default collection so that get_playlist() always works 2272 return self.playlist_class(name=name, collection=self.collection) 2273 2274 2275# vim: et sts=4 sw=4 2276