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