1# -*- coding: utf-8 -*-
2# This file is part of beets.
3# Copyright 2016, Adrian Sampson.
4#
5# Permission is hereby granted, free of charge, to any person obtaining
6# a copy of this software and associated documentation files (the
7# "Software"), to deal in the Software without restriction, including
8# without limitation the rights to use, copy, modify, merge, publish,
9# distribute, sublicense, and/or sell copies of the Software, and to
10# permit persons to whom the Software is furnished to do so, subject to
11# the following conditions:
12#
13# The above copyright notice and this permission notice shall be
14# included in all copies or substantial portions of the Software.
15
16"""The core data store and collection logic for beets.
17"""
18from __future__ import division, absolute_import, print_function
19
20import os
21import sys
22import unicodedata
23import time
24import re
25import six
26import string
27
28from beets import logging
29from beets.mediafile import MediaFile, UnreadableFileError
30from beets import plugins
31from beets import util
32from beets.util import bytestring_path, syspath, normpath, samefile, \
33    MoveOperation, lazy_property
34from beets.util.functemplate import template, Template
35from beets import dbcore
36from beets.dbcore import types
37import beets
38
39# To use the SQLite "blob" type, it doesn't suffice to provide a byte
40# string; SQLite treats that as encoded text. Wrapping it in a `buffer` or a
41# `memoryview`, depending on the Python version, tells it that we
42# actually mean non-text data.
43if six.PY2:
44    BLOB_TYPE = buffer  # noqa: F821
45else:
46    BLOB_TYPE = memoryview
47
48log = logging.getLogger('beets')
49
50
51# Library-specific query types.
52
53class PathQuery(dbcore.FieldQuery):
54    """A query that matches all items under a given path.
55
56    Matching can either be case-insensitive or case-sensitive. By
57    default, the behavior depends on the OS: case-insensitive on Windows
58    and case-sensitive otherwise.
59    """
60
61    def __init__(self, field, pattern, fast=True, case_sensitive=None):
62        """Create a path query. `pattern` must be a path, either to a
63        file or a directory.
64
65        `case_sensitive` can be a bool or `None`, indicating that the
66        behavior should depend on the filesystem.
67        """
68        super(PathQuery, self).__init__(field, pattern, fast)
69
70        # By default, the case sensitivity depends on the filesystem
71        # that the query path is located on.
72        if case_sensitive is None:
73            path = util.bytestring_path(util.normpath(pattern))
74            case_sensitive = beets.util.case_sensitive(path)
75        self.case_sensitive = case_sensitive
76
77        # Use a normalized-case pattern for case-insensitive matches.
78        if not case_sensitive:
79            pattern = pattern.lower()
80
81        # Match the path as a single file.
82        self.file_path = util.bytestring_path(util.normpath(pattern))
83        # As a directory (prefix).
84        self.dir_path = util.bytestring_path(os.path.join(self.file_path, b''))
85
86    @classmethod
87    def is_path_query(cls, query_part):
88        """Try to guess whether a unicode query part is a path query.
89
90        Condition: separator precedes colon and the file exists.
91        """
92        colon = query_part.find(':')
93        if colon != -1:
94            query_part = query_part[:colon]
95
96        # Test both `sep` and `altsep` (i.e., both slash and backslash on
97        # Windows).
98        return (
99            (os.sep in query_part or
100             (os.altsep and os.altsep in query_part)) and
101            os.path.exists(syspath(normpath(query_part)))
102        )
103
104    def match(self, item):
105        path = item.path if self.case_sensitive else item.path.lower()
106        return (path == self.file_path) or path.startswith(self.dir_path)
107
108    def col_clause(self):
109        file_blob = BLOB_TYPE(self.file_path)
110        dir_blob = BLOB_TYPE(self.dir_path)
111
112        if self.case_sensitive:
113            query_part = '({0} = ?) || (substr({0}, 1, ?) = ?)'
114        else:
115            query_part = '(BYTELOWER({0}) = BYTELOWER(?)) || \
116                         (substr(BYTELOWER({0}), 1, ?) = BYTELOWER(?))'
117
118        return query_part.format(self.field), \
119            (file_blob, len(dir_blob), dir_blob)
120
121
122# Library-specific field types.
123
124class DateType(types.Float):
125    # TODO representation should be `datetime` object
126    # TODO distinguish between date and time types
127    query = dbcore.query.DateQuery
128
129    def format(self, value):
130        return time.strftime(beets.config['time_format'].as_str(),
131                             time.localtime(value or 0))
132
133    def parse(self, string):
134        try:
135            # Try a formatted date string.
136            return time.mktime(
137                time.strptime(string,
138                              beets.config['time_format'].as_str())
139            )
140        except ValueError:
141            # Fall back to a plain timestamp number.
142            try:
143                return float(string)
144            except ValueError:
145                return self.null
146
147
148class PathType(types.Type):
149    """A dbcore type for filesystem paths. These are represented as
150    `bytes` objects, in keeping with the Unix filesystem abstraction.
151    """
152
153    sql = u'BLOB'
154    query = PathQuery
155    model_type = bytes
156
157    def __init__(self, nullable=False):
158        """Create a path type object. `nullable` controls whether the
159        type may be missing, i.e., None.
160        """
161        self.nullable = nullable
162
163    @property
164    def null(self):
165        if self.nullable:
166            return None
167        else:
168            return b''
169
170    def format(self, value):
171        return util.displayable_path(value)
172
173    def parse(self, string):
174        return normpath(bytestring_path(string))
175
176    def normalize(self, value):
177        if isinstance(value, six.text_type):
178            # Paths stored internally as encoded bytes.
179            return bytestring_path(value)
180
181        elif isinstance(value, BLOB_TYPE):
182            # We unwrap buffers to bytes.
183            return bytes(value)
184
185        else:
186            return value
187
188    def from_sql(self, sql_value):
189        return self.normalize(sql_value)
190
191    def to_sql(self, value):
192        if isinstance(value, bytes):
193            value = BLOB_TYPE(value)
194        return value
195
196
197class MusicalKey(types.String):
198    """String representing the musical key of a song.
199
200    The standard format is C, Cm, C#, C#m, etc.
201    """
202    ENHARMONIC = {
203        r'db': 'c#',
204        r'eb': 'd#',
205        r'gb': 'f#',
206        r'ab': 'g#',
207        r'bb': 'a#',
208    }
209
210    null = None
211
212    def parse(self, key):
213        key = key.lower()
214        for flat, sharp in self.ENHARMONIC.items():
215            key = re.sub(flat, sharp, key)
216        key = re.sub(r'[\W\s]+minor', 'm', key)
217        key = re.sub(r'[\W\s]+major', '', key)
218        return key.capitalize()
219
220    def normalize(self, key):
221        if key is None:
222            return None
223        else:
224            return self.parse(key)
225
226
227class DurationType(types.Float):
228    """Human-friendly (M:SS) representation of a time interval."""
229    query = dbcore.query.DurationQuery
230
231    def format(self, value):
232        if not beets.config['format_raw_length'].get(bool):
233            return beets.ui.human_seconds_short(value or 0.0)
234        else:
235            return value
236
237    def parse(self, string):
238        try:
239            # Try to format back hh:ss to seconds.
240            return util.raw_seconds_short(string)
241        except ValueError:
242            # Fall back to a plain float.
243            try:
244                return float(string)
245            except ValueError:
246                return self.null
247
248
249# Library-specific sort types.
250
251class SmartArtistSort(dbcore.query.Sort):
252    """Sort by artist (either album artist or track artist),
253    prioritizing the sort field over the raw field.
254    """
255    def __init__(self, model_cls, ascending=True, case_insensitive=True):
256        self.album = model_cls is Album
257        self.ascending = ascending
258        self.case_insensitive = case_insensitive
259
260    def order_clause(self):
261        order = "ASC" if self.ascending else "DESC"
262        field = 'albumartist' if self.album else 'artist'
263        collate = 'COLLATE NOCASE' if self.case_insensitive else ''
264        return ('(CASE {0}_sort WHEN NULL THEN {0} '
265                'WHEN "" THEN {0} '
266                'ELSE {0}_sort END) {1} {2}').format(field, collate, order)
267
268    def sort(self, objs):
269        if self.album:
270            field = lambda a: a.albumartist_sort or a.albumartist
271        else:
272            field = lambda i: i.artist_sort or i.artist
273
274        if self.case_insensitive:
275            key = lambda x: field(x).lower()
276        else:
277            key = field
278        return sorted(objs, key=key, reverse=not self.ascending)
279
280
281# Special path format key.
282PF_KEY_DEFAULT = 'default'
283
284
285# Exceptions.
286@six.python_2_unicode_compatible
287class FileOperationError(Exception):
288    """Indicates an error when interacting with a file on disk.
289    Possibilities include an unsupported media type, a permissions
290    error, and an unhandled Mutagen exception.
291    """
292    def __init__(self, path, reason):
293        """Create an exception describing an operation on the file at
294        `path` with the underlying (chained) exception `reason`.
295        """
296        super(FileOperationError, self).__init__(path, reason)
297        self.path = path
298        self.reason = reason
299
300    def text(self):
301        """Get a string representing the error. Describes both the
302        underlying reason and the file path in question.
303        """
304        return u'{0}: {1}'.format(
305            util.displayable_path(self.path),
306            six.text_type(self.reason)
307        )
308
309    # define __str__ as text to avoid infinite loop on super() calls
310    # with @six.python_2_unicode_compatible
311    __str__ = text
312
313
314@six.python_2_unicode_compatible
315class ReadError(FileOperationError):
316    """An error while reading a file (i.e. in `Item.read`).
317    """
318    def __str__(self):
319        return u'error reading ' + super(ReadError, self).text()
320
321
322@six.python_2_unicode_compatible
323class WriteError(FileOperationError):
324    """An error while writing a file (i.e. in `Item.write`).
325    """
326    def __str__(self):
327        return u'error writing ' + super(WriteError, self).text()
328
329
330# Item and Album model classes.
331
332@six.python_2_unicode_compatible
333class LibModel(dbcore.Model):
334    """Shared concrete functionality for Items and Albums.
335    """
336
337    _format_config_key = None
338    """Config key that specifies how an instance should be formatted.
339    """
340
341    def _template_funcs(self):
342        funcs = DefaultTemplateFunctions(self, self._db).functions()
343        funcs.update(plugins.template_funcs())
344        return funcs
345
346    def store(self, fields=None):
347        super(LibModel, self).store(fields)
348        plugins.send('database_change', lib=self._db, model=self)
349
350    def remove(self):
351        super(LibModel, self).remove()
352        plugins.send('database_change', lib=self._db, model=self)
353
354    def add(self, lib=None):
355        super(LibModel, self).add(lib)
356        plugins.send('database_change', lib=self._db, model=self)
357
358    def __format__(self, spec):
359        if not spec:
360            spec = beets.config[self._format_config_key].as_str()
361        assert isinstance(spec, six.text_type)
362        return self.evaluate_template(spec)
363
364    def __str__(self):
365        return format(self)
366
367    def __bytes__(self):
368        return self.__str__().encode('utf-8')
369
370
371class FormattedItemMapping(dbcore.db.FormattedMapping):
372    """Add lookup for album-level fields.
373
374    Album-level fields take precedence if `for_path` is true.
375    """
376
377    def __init__(self, item, for_path=False):
378        super(FormattedItemMapping, self).__init__(item, for_path)
379        self.item = item
380
381    @lazy_property
382    def all_keys(self):
383        return set(self.model_keys).union(self.album_keys)
384
385    @lazy_property
386    def album_keys(self):
387        album_keys = []
388        if self.album:
389            for key in self.album.keys(True):
390                if key in Album.item_keys \
391                        or key not in self.item._fields.keys():
392                    album_keys.append(key)
393        return album_keys
394
395    @lazy_property
396    def album(self):
397        return self.item.get_album()
398
399    def _get(self, key):
400        """Get the value for a key, either from the album or the item.
401        Raise a KeyError for invalid keys.
402        """
403        if self.for_path and key in self.album_keys:
404            return self._get_formatted(self.album, key)
405        elif key in self.model_keys:
406            return self._get_formatted(self.model, key)
407        elif key in self.album_keys:
408            return self._get_formatted(self.album, key)
409        else:
410            raise KeyError(key)
411
412    def __getitem__(self, key):
413        """Get the value for a key. Certain unset values are remapped.
414        """
415        value = self._get(key)
416
417        # `artist` and `albumartist` fields fall back to one another.
418        # This is helpful in path formats when the album artist is unset
419        # on as-is imports.
420        if key == 'artist' and not value:
421            return self._get('albumartist')
422        elif key == 'albumartist' and not value:
423            return self._get('artist')
424        else:
425            return value
426
427    def __iter__(self):
428        return iter(self.all_keys)
429
430    def __len__(self):
431        return len(self.all_keys)
432
433
434class Item(LibModel):
435    _table = 'items'
436    _flex_table = 'item_attributes'
437    _fields = {
438        'id':       types.PRIMARY_ID,
439        'path':     PathType(),
440        'album_id': types.FOREIGN_ID,
441
442        'title':                types.STRING,
443        'artist':               types.STRING,
444        'artist_sort':          types.STRING,
445        'artist_credit':        types.STRING,
446        'album':                types.STRING,
447        'albumartist':          types.STRING,
448        'albumartist_sort':     types.STRING,
449        'albumartist_credit':   types.STRING,
450        'genre':                types.STRING,
451        'lyricist':             types.STRING,
452        'composer':             types.STRING,
453        'composer_sort':        types.STRING,
454        'arranger':             types.STRING,
455        'grouping':             types.STRING,
456        'year':                 types.PaddedInt(4),
457        'month':                types.PaddedInt(2),
458        'day':                  types.PaddedInt(2),
459        'track':                types.PaddedInt(2),
460        'tracktotal':           types.PaddedInt(2),
461        'disc':                 types.PaddedInt(2),
462        'disctotal':            types.PaddedInt(2),
463        'lyrics':               types.STRING,
464        'comments':             types.STRING,
465        'bpm':                  types.INTEGER,
466        'comp':                 types.BOOLEAN,
467        'mb_trackid':           types.STRING,
468        'mb_albumid':           types.STRING,
469        'mb_artistid':          types.STRING,
470        'mb_albumartistid':     types.STRING,
471        'mb_releasetrackid':    types.STRING,
472        'albumtype':            types.STRING,
473        'label':                types.STRING,
474        'acoustid_fingerprint': types.STRING,
475        'acoustid_id':          types.STRING,
476        'mb_releasegroupid':    types.STRING,
477        'asin':                 types.STRING,
478        'catalognum':           types.STRING,
479        'script':               types.STRING,
480        'language':             types.STRING,
481        'country':              types.STRING,
482        'albumstatus':          types.STRING,
483        'media':                types.STRING,
484        'albumdisambig':        types.STRING,
485        'releasegroupdisambig': types.STRING,
486        'disctitle':            types.STRING,
487        'encoder':              types.STRING,
488        'rg_track_gain':        types.NULL_FLOAT,
489        'rg_track_peak':        types.NULL_FLOAT,
490        'rg_album_gain':        types.NULL_FLOAT,
491        'rg_album_peak':        types.NULL_FLOAT,
492        'r128_track_gain':      types.NullPaddedInt(6),
493        'r128_album_gain':      types.NullPaddedInt(6),
494        'original_year':        types.PaddedInt(4),
495        'original_month':       types.PaddedInt(2),
496        'original_day':         types.PaddedInt(2),
497        'initial_key':          MusicalKey(),
498
499        'length':      DurationType(),
500        'bitrate':     types.ScaledInt(1000, u'kbps'),
501        'format':      types.STRING,
502        'samplerate':  types.ScaledInt(1000, u'kHz'),
503        'bitdepth':    types.INTEGER,
504        'channels':    types.INTEGER,
505        'mtime':       DateType(),
506        'added':       DateType(),
507    }
508
509    _search_fields = ('artist', 'title', 'comments',
510                      'album', 'albumartist', 'genre')
511
512    _types = {
513        'data_source': types.STRING,
514    }
515
516    _media_fields = set(MediaFile.readable_fields()) \
517        .intersection(_fields.keys())
518    """Set of item fields that are backed by `MediaFile` fields.
519
520    Any kind of field (fixed, flexible, and computed) may be a media
521    field. Only these fields are read from disk in `read` and written in
522    `write`.
523    """
524
525    _media_tag_fields = set(MediaFile.fields()).intersection(_fields.keys())
526    """Set of item fields that are backed by *writable* `MediaFile` tag
527    fields.
528
529    This excludes fields that represent audio data, such as `bitrate` or
530    `length`.
531    """
532
533    _formatter = FormattedItemMapping
534
535    _sorts = {'artist': SmartArtistSort}
536
537    _format_config_key = 'format_item'
538
539    @classmethod
540    def _getters(cls):
541        getters = plugins.item_field_getters()
542        getters['singleton'] = lambda i: i.album_id is None
543        getters['filesize'] = Item.try_filesize  # In bytes.
544        return getters
545
546    @classmethod
547    def from_path(cls, path):
548        """Creates a new item from the media file at the specified path.
549        """
550        # Initiate with values that aren't read from files.
551        i = cls(album_id=None)
552        i.read(path)
553        i.mtime = i.current_mtime()  # Initial mtime.
554        return i
555
556    def __setitem__(self, key, value):
557        """Set the item's value for a standard field or a flexattr.
558        """
559        # Encode unicode paths and read buffers.
560        if key == 'path':
561            if isinstance(value, six.text_type):
562                value = bytestring_path(value)
563            elif isinstance(value, BLOB_TYPE):
564                value = bytes(value)
565
566        changed = super(Item, self)._setitem(key, value)
567
568        if changed and key in MediaFile.fields():
569            self.mtime = 0  # Reset mtime on dirty.
570
571    def update(self, values):
572        """Set all key/value pairs in the mapping. If mtime is
573        specified, it is not reset (as it might otherwise be).
574        """
575        super(Item, self).update(values)
576        if self.mtime == 0 and 'mtime' in values:
577            self.mtime = values['mtime']
578
579    def clear(self):
580        """Set all key/value pairs to None."""
581        for key in self._media_tag_fields:
582            setattr(self, key, None)
583
584    def get_album(self):
585        """Get the Album object that this item belongs to, if any, or
586        None if the item is a singleton or is not associated with a
587        library.
588        """
589        if not self._db:
590            return None
591        return self._db.get_album(self)
592
593    # Interaction with file metadata.
594
595    def read(self, read_path=None):
596        """Read the metadata from the associated file.
597
598        If `read_path` is specified, read metadata from that file
599        instead. Updates all the properties in `_media_fields`
600        from the media file.
601
602        Raises a `ReadError` if the file could not be read.
603        """
604        if read_path is None:
605            read_path = self.path
606        else:
607            read_path = normpath(read_path)
608        try:
609            mediafile = MediaFile(syspath(read_path))
610        except UnreadableFileError as exc:
611            raise ReadError(read_path, exc)
612
613        for key in self._media_fields:
614            value = getattr(mediafile, key)
615            if isinstance(value, six.integer_types):
616                if value.bit_length() > 63:
617                    value = 0
618            self[key] = value
619
620        # Database's mtime should now reflect the on-disk value.
621        if read_path == self.path:
622            self.mtime = self.current_mtime()
623
624        self.path = read_path
625
626    def write(self, path=None, tags=None, id3v23=None):
627        """Write the item's metadata to a media file.
628
629        All fields in `_media_fields` are written to disk according to
630        the values on this object.
631
632        `path` is the path of the mediafile to write the data to. It
633        defaults to the item's path.
634
635        `tags` is a dictionary of additional metadata the should be
636        written to the file. (These tags need not be in `_media_fields`.)
637
638        `id3v23` will override the global `id3v23` config option if it is
639        set to something other than `None`.
640
641        Can raise either a `ReadError` or a `WriteError`.
642        """
643        if path is None:
644            path = self.path
645        else:
646            path = normpath(path)
647
648        if id3v23 is None:
649            id3v23 = beets.config['id3v23'].get(bool)
650
651        # Get the data to write to the file.
652        item_tags = dict(self)
653        item_tags = {k: v for k, v in item_tags.items()
654                     if k in self._media_fields}  # Only write media fields.
655        if tags is not None:
656            item_tags.update(tags)
657        plugins.send('write', item=self, path=path, tags=item_tags)
658
659        # Open the file.
660        try:
661            mediafile = MediaFile(syspath(path), id3v23=id3v23)
662        except UnreadableFileError as exc:
663            raise ReadError(path, exc)
664
665        # Write the tags to the file.
666        mediafile.update(item_tags)
667        try:
668            mediafile.save()
669        except UnreadableFileError as exc:
670            raise WriteError(self.path, exc)
671
672        # The file has a new mtime.
673        if path == self.path:
674            self.mtime = self.current_mtime()
675        plugins.send('after_write', item=self, path=path)
676
677    def try_write(self, *args, **kwargs):
678        """Calls `write()` but catches and logs `FileOperationError`
679        exceptions.
680
681        Returns `False` an exception was caught and `True` otherwise.
682        """
683        try:
684            self.write(*args, **kwargs)
685            return True
686        except FileOperationError as exc:
687            log.error(u"{0}", exc)
688            return False
689
690    def try_sync(self, write, move, with_album=True):
691        """Synchronize the item with the database and, possibly, updates its
692        tags on disk and its path (by moving the file).
693
694        `write` indicates whether to write new tags into the file. Similarly,
695        `move` controls whether the path should be updated. In the
696        latter case, files are *only* moved when they are inside their
697        library's directory (if any).
698
699        Similar to calling :meth:`write`, :meth:`move`, and :meth:`store`
700        (conditionally).
701        """
702        if write:
703            self.try_write()
704        if move:
705            # Check whether this file is inside the library directory.
706            if self._db and self._db.directory in util.ancestry(self.path):
707                log.debug(u'moving {0} to synchronize path',
708                          util.displayable_path(self.path))
709                self.move(with_album=with_album)
710        self.store()
711
712    # Files themselves.
713
714    def move_file(self, dest, operation=MoveOperation.MOVE):
715        """Move, copy, link or hardlink the item's depending on `operation`,
716        updating the path value if the move succeeds.
717
718        If a file exists at `dest`, then it is slightly modified to be unique.
719
720        `operation` should be an instance of `util.MoveOperation`.
721        """
722        if not util.samefile(self.path, dest):
723            dest = util.unique_path(dest)
724        if operation == MoveOperation.MOVE:
725            plugins.send("before_item_moved", item=self, source=self.path,
726                         destination=dest)
727            util.move(self.path, dest)
728            plugins.send("item_moved", item=self, source=self.path,
729                         destination=dest)
730        elif operation == MoveOperation.COPY:
731            util.copy(self.path, dest)
732            plugins.send("item_copied", item=self, source=self.path,
733                         destination=dest)
734        elif operation == MoveOperation.LINK:
735            util.link(self.path, dest)
736            plugins.send("item_linked", item=self, source=self.path,
737                         destination=dest)
738        elif operation == MoveOperation.HARDLINK:
739            util.hardlink(self.path, dest)
740            plugins.send("item_hardlinked", item=self, source=self.path,
741                         destination=dest)
742
743        # Either copying or moving succeeded, so update the stored path.
744        self.path = dest
745
746    def current_mtime(self):
747        """Returns the current mtime of the file, rounded to the nearest
748        integer.
749        """
750        return int(os.path.getmtime(syspath(self.path)))
751
752    def try_filesize(self):
753        """Get the size of the underlying file in bytes.
754
755        If the file is missing, return 0 (and log a warning).
756        """
757        try:
758            return os.path.getsize(syspath(self.path))
759        except (OSError, Exception) as exc:
760            log.warning(u'could not get filesize: {0}', exc)
761            return 0
762
763    # Model methods.
764
765    def remove(self, delete=False, with_album=True):
766        """Removes the item. If `delete`, then the associated file is
767        removed from disk. If `with_album`, then the item's album (if
768        any) is removed if it the item was the last in the album.
769        """
770        super(Item, self).remove()
771
772        # Remove the album if it is empty.
773        if with_album:
774            album = self.get_album()
775            if album and not album.items():
776                album.remove(delete, False)
777
778        # Send a 'item_removed' signal to plugins
779        plugins.send('item_removed', item=self)
780
781        # Delete the associated file.
782        if delete:
783            util.remove(self.path)
784            util.prune_dirs(os.path.dirname(self.path), self._db.directory)
785
786        self._db._memotable = {}
787
788    def move(self, operation=MoveOperation.MOVE, basedir=None,
789             with_album=True, store=True):
790        """Move the item to its designated location within the library
791        directory (provided by destination()). Subdirectories are
792        created as needed. If the operation succeeds, the item's path
793        field is updated to reflect the new location.
794
795        Instead of moving the item it can also be copied, linked or hardlinked
796        depending on `operation` which should be an instance of
797        `util.MoveOperation`.
798
799        `basedir` overrides the library base directory for the destination.
800
801        If the item is in an album and `with_album` is `True`, the album is
802        given an opportunity to move its art.
803
804        By default, the item is stored to the database if it is in the
805        database, so any dirty fields prior to the move() call will be written
806        as a side effect.
807        If `store` is `False` however, the item won't be stored and you'll
808        have to manually store it after invoking this method.
809        """
810        self._check_db()
811        dest = self.destination(basedir=basedir)
812
813        # Create necessary ancestry for the move.
814        util.mkdirall(dest)
815
816        # Perform the move and store the change.
817        old_path = self.path
818        self.move_file(dest, operation)
819        if store:
820            self.store()
821
822        # If this item is in an album, move its art.
823        if with_album:
824            album = self.get_album()
825            if album:
826                album.move_art(operation)
827                if store:
828                    album.store()
829
830        # Prune vacated directory.
831        if operation == MoveOperation.MOVE:
832            util.prune_dirs(os.path.dirname(old_path), self._db.directory)
833
834    # Templating.
835
836    def destination(self, fragment=False, basedir=None, platform=None,
837                    path_formats=None):
838        """Returns the path in the library directory designated for the
839        item (i.e., where the file ought to be). fragment makes this
840        method return just the path fragment underneath the root library
841        directory; the path is also returned as Unicode instead of
842        encoded as a bytestring. basedir can override the library's base
843        directory for the destination.
844        """
845        self._check_db()
846        platform = platform or sys.platform
847        basedir = basedir or self._db.directory
848        path_formats = path_formats or self._db.path_formats
849
850        # Use a path format based on a query, falling back on the
851        # default.
852        for query, path_format in path_formats:
853            if query == PF_KEY_DEFAULT:
854                continue
855            query, _ = parse_query_string(query, type(self))
856            if query.match(self):
857                # The query matches the item! Use the corresponding path
858                # format.
859                break
860        else:
861            # No query matched; fall back to default.
862            for query, path_format in path_formats:
863                if query == PF_KEY_DEFAULT:
864                    break
865            else:
866                assert False, u"no default path format"
867        if isinstance(path_format, Template):
868            subpath_tmpl = path_format
869        else:
870            subpath_tmpl = template(path_format)
871
872        # Evaluate the selected template.
873        subpath = self.evaluate_template(subpath_tmpl, True)
874
875        # Prepare path for output: normalize Unicode characters.
876        if platform == 'darwin':
877            subpath = unicodedata.normalize('NFD', subpath)
878        else:
879            subpath = unicodedata.normalize('NFC', subpath)
880
881        if beets.config['asciify_paths']:
882            subpath = util.asciify_path(
883                subpath,
884                beets.config['path_sep_replace'].as_str()
885            )
886
887        maxlen = beets.config['max_filename_length'].get(int)
888        if not maxlen:
889            # When zero, try to determine from filesystem.
890            maxlen = util.max_filename_length(self._db.directory)
891
892        subpath, fellback = util.legalize_path(
893            subpath, self._db.replacements, maxlen,
894            os.path.splitext(self.path)[1], fragment
895        )
896        if fellback:
897            # Print an error message if legalization fell back to
898            # default replacements because of the maximum length.
899            log.warning(
900                u'Fell back to default replacements when naming '
901                u'file {}. Configure replacements to avoid lengthening '
902                u'the filename.',
903                subpath
904            )
905
906        if fragment:
907            return util.as_string(subpath)
908        else:
909            return normpath(os.path.join(basedir, subpath))
910
911
912class Album(LibModel):
913    """Provides access to information about albums stored in a
914    library. Reflects the library's "albums" table, including album
915    art.
916    """
917    _table = 'albums'
918    _flex_table = 'album_attributes'
919    _always_dirty = True
920    _fields = {
921        'id':      types.PRIMARY_ID,
922        'artpath': PathType(True),
923        'added':   DateType(),
924
925        'albumartist':          types.STRING,
926        'albumartist_sort':     types.STRING,
927        'albumartist_credit':   types.STRING,
928        'album':                types.STRING,
929        'genre':                types.STRING,
930        'year':                 types.PaddedInt(4),
931        'month':                types.PaddedInt(2),
932        'day':                  types.PaddedInt(2),
933        'disctotal':            types.PaddedInt(2),
934        'comp':                 types.BOOLEAN,
935        'mb_albumid':           types.STRING,
936        'mb_albumartistid':     types.STRING,
937        'albumtype':            types.STRING,
938        'label':                types.STRING,
939        'mb_releasegroupid':    types.STRING,
940        'asin':                 types.STRING,
941        'catalognum':           types.STRING,
942        'script':               types.STRING,
943        'language':             types.STRING,
944        'country':              types.STRING,
945        'albumstatus':          types.STRING,
946        'albumdisambig':        types.STRING,
947        'releasegroupdisambig': types.STRING,
948        'rg_album_gain':        types.NULL_FLOAT,
949        'rg_album_peak':        types.NULL_FLOAT,
950        'r128_album_gain':      types.NullPaddedInt(6),
951        'original_year':        types.PaddedInt(4),
952        'original_month':       types.PaddedInt(2),
953        'original_day':         types.PaddedInt(2),
954    }
955
956    _search_fields = ('album', 'albumartist', 'genre')
957
958    _types = {
959        'path':        PathType(),
960        'data_source': types.STRING,
961    }
962
963    _sorts = {
964        'albumartist': SmartArtistSort,
965        'artist': SmartArtistSort,
966    }
967
968    item_keys = [
969        'added',
970        'albumartist',
971        'albumartist_sort',
972        'albumartist_credit',
973        'album',
974        'genre',
975        'year',
976        'month',
977        'day',
978        'disctotal',
979        'comp',
980        'mb_albumid',
981        'mb_albumartistid',
982        'albumtype',
983        'label',
984        'mb_releasegroupid',
985        'asin',
986        'catalognum',
987        'script',
988        'language',
989        'country',
990        'albumstatus',
991        'albumdisambig',
992        'releasegroupdisambig',
993        'rg_album_gain',
994        'rg_album_peak',
995        'r128_album_gain',
996        'original_year',
997        'original_month',
998        'original_day',
999    ]
1000    """List of keys that are set on an album's items.
1001    """
1002
1003    _format_config_key = 'format_album'
1004
1005    @classmethod
1006    def _getters(cls):
1007        # In addition to plugin-provided computed fields, also expose
1008        # the album's directory as `path`.
1009        getters = plugins.album_field_getters()
1010        getters['path'] = Album.item_dir
1011        getters['albumtotal'] = Album._albumtotal
1012        return getters
1013
1014    def items(self):
1015        """Returns an iterable over the items associated with this
1016        album.
1017        """
1018        return self._db.items(dbcore.MatchQuery('album_id', self.id))
1019
1020    def remove(self, delete=False, with_items=True):
1021        """Removes this album and all its associated items from the
1022        library. If delete, then the items' files are also deleted
1023        from disk, along with any album art. The directories
1024        containing the album are also removed (recursively) if empty.
1025        Set with_items to False to avoid removing the album's items.
1026        """
1027        super(Album, self).remove()
1028
1029        # Delete art file.
1030        if delete:
1031            artpath = self.artpath
1032            if artpath:
1033                util.remove(artpath)
1034
1035        # Remove (and possibly delete) the constituent items.
1036        if with_items:
1037            for item in self.items():
1038                item.remove(delete, False)
1039
1040    def move_art(self, operation=MoveOperation.MOVE):
1041        """Move, copy, link or hardlink (depending on `operation`) any
1042        existing album art so that it remains in the same directory as
1043        the items.
1044
1045        `operation` should be an instance of `util.MoveOperation`.
1046        """
1047        old_art = self.artpath
1048        if not old_art:
1049            return
1050
1051        if not os.path.exists(old_art):
1052            log.error(u'removing reference to missing album art file {}',
1053                      util.displayable_path(old_art))
1054            self.artpath = None
1055            return
1056
1057        new_art = self.art_destination(old_art)
1058        if new_art == old_art:
1059            return
1060
1061        new_art = util.unique_path(new_art)
1062        log.debug(u'moving album art {0} to {1}',
1063                  util.displayable_path(old_art),
1064                  util.displayable_path(new_art))
1065        if operation == MoveOperation.MOVE:
1066            util.move(old_art, new_art)
1067            util.prune_dirs(os.path.dirname(old_art), self._db.directory)
1068        elif operation == MoveOperation.COPY:
1069            util.copy(old_art, new_art)
1070        elif operation == MoveOperation.LINK:
1071            util.link(old_art, new_art)
1072        elif operation == MoveOperation.HARDLINK:
1073            util.hardlink(old_art, new_art)
1074        self.artpath = new_art
1075
1076    def move(self, operation=MoveOperation.MOVE, basedir=None, store=True):
1077        """Move, copy, link or hardlink (depending on `operation`)
1078        all items to their destination. Any album art moves along with them.
1079
1080        `basedir` overrides the library base directory for the destination.
1081
1082        `operation` should be an instance of `util.MoveOperation`.
1083
1084        By default, the album is stored to the database, persisting any
1085        modifications to its metadata. If `store` is `False` however,
1086        the album is not stored automatically, and you'll have to manually
1087        store it after invoking this method.
1088        """
1089        basedir = basedir or self._db.directory
1090
1091        # Ensure new metadata is available to items for destination
1092        # computation.
1093        if store:
1094            self.store()
1095
1096        # Move items.
1097        items = list(self.items())
1098        for item in items:
1099            item.move(operation, basedir=basedir, with_album=False,
1100                      store=store)
1101
1102        # Move art.
1103        self.move_art(operation)
1104        if store:
1105            self.store()
1106
1107    def item_dir(self):
1108        """Returns the directory containing the album's first item,
1109        provided that such an item exists.
1110        """
1111        item = self.items().get()
1112        if not item:
1113            raise ValueError(u'empty album')
1114        return os.path.dirname(item.path)
1115
1116    def _albumtotal(self):
1117        """Return the total number of tracks on all discs on the album
1118        """
1119        if self.disctotal == 1 or not beets.config['per_disc_numbering']:
1120            return self.items()[0].tracktotal
1121
1122        counted = []
1123        total = 0
1124
1125        for item in self.items():
1126            if item.disc in counted:
1127                continue
1128
1129            total += item.tracktotal
1130            counted.append(item.disc)
1131
1132            if len(counted) == self.disctotal:
1133                break
1134
1135        return total
1136
1137    def art_destination(self, image, item_dir=None):
1138        """Returns a path to the destination for the album art image
1139        for the album. `image` is the path of the image that will be
1140        moved there (used for its extension).
1141
1142        The path construction uses the existing path of the album's
1143        items, so the album must contain at least one item or
1144        item_dir must be provided.
1145        """
1146        image = bytestring_path(image)
1147        item_dir = item_dir or self.item_dir()
1148
1149        filename_tmpl = template(
1150            beets.config['art_filename'].as_str())
1151        subpath = self.evaluate_template(filename_tmpl, True)
1152        if beets.config['asciify_paths']:
1153            subpath = util.asciify_path(
1154                subpath,
1155                beets.config['path_sep_replace'].as_str()
1156            )
1157        subpath = util.sanitize_path(subpath,
1158                                     replacements=self._db.replacements)
1159        subpath = bytestring_path(subpath)
1160
1161        _, ext = os.path.splitext(image)
1162        dest = os.path.join(item_dir, subpath + ext)
1163
1164        return bytestring_path(dest)
1165
1166    def set_art(self, path, copy=True):
1167        """Sets the album's cover art to the image at the given path.
1168        The image is copied (or moved) into place, replacing any
1169        existing art.
1170
1171        Sends an 'art_set' event with `self` as the sole argument.
1172        """
1173        path = bytestring_path(path)
1174        oldart = self.artpath
1175        artdest = self.art_destination(path)
1176
1177        if oldart and samefile(path, oldart):
1178            # Art already set.
1179            return
1180        elif samefile(path, artdest):
1181            # Art already in place.
1182            self.artpath = path
1183            return
1184
1185        # Normal operation.
1186        if oldart == artdest:
1187            util.remove(oldart)
1188        artdest = util.unique_path(artdest)
1189        if copy:
1190            util.copy(path, artdest)
1191        else:
1192            util.move(path, artdest)
1193        self.artpath = artdest
1194
1195        plugins.send('art_set', album=self)
1196
1197    def store(self, fields=None):
1198        """Update the database with the album information. The album's
1199        tracks are also updated.
1200        :param fields: The fields to be stored. If not specified, all fields
1201        will be.
1202        """
1203        # Get modified track fields.
1204        track_updates = {}
1205        for key in self.item_keys:
1206            if key in self._dirty:
1207                track_updates[key] = self[key]
1208
1209        with self._db.transaction():
1210            super(Album, self).store(fields)
1211            if track_updates:
1212                for item in self.items():
1213                    for key, value in track_updates.items():
1214                        item[key] = value
1215                    item.store()
1216
1217    def try_sync(self, write, move):
1218        """Synchronize the album and its items with the database.
1219        Optionally, also write any new tags into the files and update
1220        their paths.
1221
1222        `write` indicates whether to write tags to the item files, and
1223        `move` controls whether files (both audio and album art) are
1224        moved.
1225        """
1226        self.store()
1227        for item in self.items():
1228            item.try_sync(write, move)
1229
1230
1231# Query construction helpers.
1232
1233def parse_query_parts(parts, model_cls):
1234    """Given a beets query string as a list of components, return the
1235    `Query` and `Sort` they represent.
1236
1237    Like `dbcore.parse_sorted_query`, with beets query prefixes and
1238    special path query detection.
1239    """
1240    # Get query types and their prefix characters.
1241    prefixes = {':': dbcore.query.RegexpQuery}
1242    prefixes.update(plugins.queries())
1243
1244    # Special-case path-like queries, which are non-field queries
1245    # containing path separators (/).
1246    path_parts = []
1247    non_path_parts = []
1248    for s in parts:
1249        if PathQuery.is_path_query(s):
1250            path_parts.append(s)
1251        else:
1252            non_path_parts.append(s)
1253
1254    case_insensitive = beets.config['sort_case_insensitive'].get(bool)
1255
1256    query, sort = dbcore.parse_sorted_query(
1257        model_cls, non_path_parts, prefixes, case_insensitive
1258    )
1259
1260    # Add path queries to aggregate query.
1261    # Match field / flexattr depending on whether the model has the path field
1262    fast_path_query = 'path' in model_cls._fields
1263    query.subqueries += [PathQuery('path', s, fast_path_query)
1264                         for s in path_parts]
1265
1266    return query, sort
1267
1268
1269def parse_query_string(s, model_cls):
1270    """Given a beets query string, return the `Query` and `Sort` they
1271    represent.
1272
1273    The string is split into components using shell-like syntax.
1274    """
1275    message = u"Query is not unicode: {0!r}".format(s)
1276    assert isinstance(s, six.text_type), message
1277    try:
1278        parts = util.shlex_split(s)
1279    except ValueError as exc:
1280        raise dbcore.InvalidQueryError(s, exc)
1281    return parse_query_parts(parts, model_cls)
1282
1283
1284def _sqlite_bytelower(bytestring):
1285    """ A custom ``bytelower`` sqlite function so we can compare
1286        bytestrings in a semi case insensitive fashion.  This is to work
1287        around sqlite builds are that compiled with
1288        ``-DSQLITE_LIKE_DOESNT_MATCH_BLOBS``. See
1289        ``https://github.com/beetbox/beets/issues/2172`` for details.
1290    """
1291    if not six.PY2:
1292        return bytestring.lower()
1293
1294    return buffer(bytes(bytestring).lower())  # noqa: F821
1295
1296
1297# The Library: interface to the database.
1298
1299class Library(dbcore.Database):
1300    """A database of music containing songs and albums.
1301    """
1302    _models = (Item, Album)
1303
1304    def __init__(self, path='library.blb',
1305                 directory='~/Music',
1306                 path_formats=((PF_KEY_DEFAULT,
1307                               '$artist/$album/$track $title'),),
1308                 replacements=None):
1309        timeout = beets.config['timeout'].as_number()
1310        super(Library, self).__init__(path, timeout=timeout)
1311
1312        self.directory = bytestring_path(normpath(directory))
1313        self.path_formats = path_formats
1314        self.replacements = replacements
1315
1316        self._memotable = {}  # Used for template substitution performance.
1317
1318    def _create_connection(self):
1319        conn = super(Library, self)._create_connection()
1320        conn.create_function('bytelower', 1, _sqlite_bytelower)
1321        return conn
1322
1323    # Adding objects to the database.
1324
1325    def add(self, obj):
1326        """Add the :class:`Item` or :class:`Album` object to the library
1327        database. Return the object's new id.
1328        """
1329        obj.add(self)
1330        self._memotable = {}
1331        return obj.id
1332
1333    def add_album(self, items):
1334        """Create a new album consisting of a list of items.
1335
1336        The items are added to the database if they don't yet have an
1337        ID. Return a new :class:`Album` object. The list items must not
1338        be empty.
1339        """
1340        if not items:
1341            raise ValueError(u'need at least one item')
1342
1343        # Create the album structure using metadata from the first item.
1344        values = dict((key, items[0][key]) for key in Album.item_keys)
1345        album = Album(self, **values)
1346
1347        # Add the album structure and set the items' album_id fields.
1348        # Store or add the items.
1349        with self.transaction():
1350            album.add(self)
1351            for item in items:
1352                item.album_id = album.id
1353                if item.id is None:
1354                    item.add(self)
1355                else:
1356                    item.store()
1357
1358        return album
1359
1360    # Querying.
1361
1362    def _fetch(self, model_cls, query, sort=None):
1363        """Parse a query and fetch. If a order specification is present
1364        in the query string the `sort` argument is ignored.
1365        """
1366        # Parse the query, if necessary.
1367        try:
1368            parsed_sort = None
1369            if isinstance(query, six.string_types):
1370                query, parsed_sort = parse_query_string(query, model_cls)
1371            elif isinstance(query, (list, tuple)):
1372                query, parsed_sort = parse_query_parts(query, model_cls)
1373        except dbcore.query.InvalidQueryArgumentValueError as exc:
1374            raise dbcore.InvalidQueryError(query, exc)
1375
1376        # Any non-null sort specified by the parsed query overrides the
1377        # provided sort.
1378        if parsed_sort and not isinstance(parsed_sort, dbcore.query.NullSort):
1379            sort = parsed_sort
1380
1381        return super(Library, self)._fetch(
1382            model_cls, query, sort
1383        )
1384
1385    @staticmethod
1386    def get_default_album_sort():
1387        """Get a :class:`Sort` object for albums from the config option.
1388        """
1389        return dbcore.sort_from_strings(
1390            Album, beets.config['sort_album'].as_str_seq())
1391
1392    @staticmethod
1393    def get_default_item_sort():
1394        """Get a :class:`Sort` object for items from the config option.
1395        """
1396        return dbcore.sort_from_strings(
1397            Item, beets.config['sort_item'].as_str_seq())
1398
1399    def albums(self, query=None, sort=None):
1400        """Get :class:`Album` objects matching the query.
1401        """
1402        return self._fetch(Album, query, sort or self.get_default_album_sort())
1403
1404    def items(self, query=None, sort=None):
1405        """Get :class:`Item` objects matching the query.
1406        """
1407        return self._fetch(Item, query, sort or self.get_default_item_sort())
1408
1409    # Convenience accessors.
1410
1411    def get_item(self, id):
1412        """Fetch an :class:`Item` by its ID. Returns `None` if no match is
1413        found.
1414        """
1415        return self._get(Item, id)
1416
1417    def get_album(self, item_or_id):
1418        """Given an album ID or an item associated with an album, return
1419        an :class:`Album` object for the album. If no such album exists,
1420        returns `None`.
1421        """
1422        if isinstance(item_or_id, int):
1423            album_id = item_or_id
1424        else:
1425            album_id = item_or_id.album_id
1426        if album_id is None:
1427            return None
1428        return self._get(Album, album_id)
1429
1430
1431# Default path template resources.
1432
1433def _int_arg(s):
1434    """Convert a string argument to an integer for use in a template
1435    function.  May raise a ValueError.
1436    """
1437    return int(s.strip())
1438
1439
1440class DefaultTemplateFunctions(object):
1441    """A container class for the default functions provided to path
1442    templates. These functions are contained in an object to provide
1443    additional context to the functions -- specifically, the Item being
1444    evaluated.
1445    """
1446    _prefix = 'tmpl_'
1447
1448    def __init__(self, item=None, lib=None):
1449        """Parametrize the functions. If `item` or `lib` is None, then
1450        some functions (namely, ``aunique``) will always evaluate to the
1451        empty string.
1452        """
1453        self.item = item
1454        self.lib = lib
1455
1456    def functions(self):
1457        """Returns a dictionary containing the functions defined in this
1458        object. The keys are function names (as exposed in templates)
1459        and the values are Python functions.
1460        """
1461        out = {}
1462        for key in self._func_names:
1463            out[key[len(self._prefix):]] = getattr(self, key)
1464        return out
1465
1466    @staticmethod
1467    def tmpl_lower(s):
1468        """Convert a string to lower case."""
1469        return s.lower()
1470
1471    @staticmethod
1472    def tmpl_upper(s):
1473        """Covert a string to upper case."""
1474        return s.upper()
1475
1476    @staticmethod
1477    def tmpl_title(s):
1478        """Convert a string to title case."""
1479        return string.capwords(s)
1480
1481    @staticmethod
1482    def tmpl_left(s, chars):
1483        """Get the leftmost characters of a string."""
1484        return s[0:_int_arg(chars)]
1485
1486    @staticmethod
1487    def tmpl_right(s, chars):
1488        """Get the rightmost characters of a string."""
1489        return s[-_int_arg(chars):]
1490
1491    @staticmethod
1492    def tmpl_if(condition, trueval, falseval=u''):
1493        """If ``condition`` is nonempty and nonzero, emit ``trueval``;
1494        otherwise, emit ``falseval`` (if provided).
1495        """
1496        try:
1497            int_condition = _int_arg(condition)
1498        except ValueError:
1499            if condition.lower() == "false":
1500                return falseval
1501        else:
1502            condition = int_condition
1503
1504        if condition:
1505            return trueval
1506        else:
1507            return falseval
1508
1509    @staticmethod
1510    def tmpl_asciify(s):
1511        """Translate non-ASCII characters to their ASCII equivalents.
1512        """
1513        return util.asciify_path(s, beets.config['path_sep_replace'].as_str())
1514
1515    @staticmethod
1516    def tmpl_time(s, fmt):
1517        """Format a time value using `strftime`.
1518        """
1519        cur_fmt = beets.config['time_format'].as_str()
1520        return time.strftime(fmt, time.strptime(s, cur_fmt))
1521
1522    def tmpl_aunique(self, keys=None, disam=None, bracket=None):
1523        """Generate a string that is guaranteed to be unique among all
1524        albums in the library who share the same set of keys. A fields
1525        from "disam" is used in the string if one is sufficient to
1526        disambiguate the albums. Otherwise, a fallback opaque value is
1527        used. Both "keys" and "disam" should be given as
1528        whitespace-separated lists of field names, while "bracket" is a
1529        pair of characters to be used as brackets surrounding the
1530        disambiguator or empty to have no brackets.
1531        """
1532        # Fast paths: no album, no item or library, or memoized value.
1533        if not self.item or not self.lib:
1534            return u''
1535
1536        if isinstance(self.item, Item):
1537            album_id = self.item.album_id
1538        elif isinstance(self.item, Album):
1539            album_id = self.item.id
1540
1541        if album_id is None:
1542            return u''
1543
1544        memokey = ('aunique', keys, disam, album_id)
1545        memoval = self.lib._memotable.get(memokey)
1546        if memoval is not None:
1547            return memoval
1548
1549        keys = keys or beets.config['aunique']['keys'].as_str()
1550        disam = disam or beets.config['aunique']['disambiguators'].as_str()
1551        if bracket is None:
1552            bracket = beets.config['aunique']['bracket'].as_str()
1553        keys = keys.split()
1554        disam = disam.split()
1555
1556        # Assign a left and right bracket or leave blank if argument is empty.
1557        if len(bracket) == 2:
1558            bracket_l = bracket[0]
1559            bracket_r = bracket[1]
1560        else:
1561            bracket_l = u''
1562            bracket_r = u''
1563
1564        album = self.lib.get_album(album_id)
1565        if not album:
1566            # Do nothing for singletons.
1567            self.lib._memotable[memokey] = u''
1568            return u''
1569
1570        # Find matching albums to disambiguate with.
1571        subqueries = []
1572        for key in keys:
1573            value = album.get(key, '')
1574            subqueries.append(dbcore.MatchQuery(key, value))
1575        albums = self.lib.albums(dbcore.AndQuery(subqueries))
1576
1577        # If there's only one album to matching these details, then do
1578        # nothing.
1579        if len(albums) == 1:
1580            self.lib._memotable[memokey] = u''
1581            return u''
1582
1583        # Find the first disambiguator that distinguishes the albums.
1584        for disambiguator in disam:
1585            # Get the value for each album for the current field.
1586            disam_values = set([a.get(disambiguator, '') for a in albums])
1587
1588            # If the set of unique values is equal to the number of
1589            # albums in the disambiguation set, we're done -- this is
1590            # sufficient disambiguation.
1591            if len(disam_values) == len(albums):
1592                break
1593
1594        else:
1595            # No disambiguator distinguished all fields.
1596            res = u' {1}{0}{2}'.format(album.id, bracket_l, bracket_r)
1597            self.lib._memotable[memokey] = res
1598            return res
1599
1600        # Flatten disambiguation value into a string.
1601        disam_value = album.formatted(True).get(disambiguator)
1602
1603        # Return empty string if disambiguator is empty.
1604        if disam_value:
1605            res = u' {1}{0}{2}'.format(disam_value, bracket_l, bracket_r)
1606        else:
1607            res = u''
1608
1609        self.lib._memotable[memokey] = res
1610        return res
1611
1612    @staticmethod
1613    def tmpl_first(s, count=1, skip=0, sep=u'; ', join_str=u'; '):
1614        """ Gets the item(s) from x to y in a string separated by something
1615        and join then with something
1616
1617        :param s: the string
1618        :param count: The number of items included
1619        :param skip: The number of items skipped
1620        :param sep: the separator. Usually is '; ' (default) or '/ '
1621        :param join_str: the string which will join the items, default '; '.
1622        """
1623        skip = int(skip)
1624        count = skip + int(count)
1625        return join_str.join(s.split(sep)[skip:count])
1626
1627    def tmpl_ifdef(self, field, trueval=u'', falseval=u''):
1628        """ If field exists return trueval or the field (default)
1629        otherwise, emit return falseval (if provided).
1630
1631        :param field: The name of the field
1632        :param trueval: The string if the condition is true
1633        :param falseval: The string if the condition is false
1634        :return: The string, based on condition
1635        """
1636        if self.item.formatted().get(field):
1637            return trueval if trueval else self.item.formatted().get(field)
1638        else:
1639            return falseval
1640
1641
1642# Get the name of tmpl_* functions in the above class.
1643DefaultTemplateFunctions._func_names = \
1644    [s for s in dir(DefaultTemplateFunctions)
1645     if s.startswith(DefaultTemplateFunctions._prefix)]
1646