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