diff --git a/beets/mediafile.py b/beets/mediafile.py index b1bd372a7..9c432477f 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -25,66 +25,6 @@ class FileTypeError(IOError): -#### utility functions #### - -def fromslashed(slashed, sep=u'/'): - """Extract a pair of items from a slashed string. If only one - value is present, it is assumed to be the left-hand value.""" - - if slashed is None: - return (None, None) - - items = slashed.split(sep) - - if len(items) == 1: - out = (items[0], None) - else: - out = (items[0], items[1]) - - # represent "nothing stored" more gracefully - if out[0] == '': out[0] = None - if out[1] == '': out[1] = None - - return out - -def toslashed(pair_or_val, sep=u'/'): - """Store a pair of items or a single item in a slashed string. If - only one value is provided (in a list/tuple or as a single value), - no slash is used.""" - if type(pair_or_val) is list or type(pair_or_val) is tuple: - if len(pair_or_val) == 0: - out = [u''] - elif len(pair_or_val) == 1: - out = [unicode(pair_or_val[0])] - else: - out = [unicode(pair_or_val[0]), unicode(pair_or_val[1])] - else: # "scalar" - out = [unicode(pair_or_val)] - return sep.join(out) - -def unpair(pair, right=False, noneval=None): - """Return the left or right value in a pair (as selected by the "right" - parameter. If the value on that side is not available, return noneval.)""" - if right: idx = 1 - else: idx = 0 - - try: - out = pair[idx] - except: - out = None - finally: - if out is None: - return noneval - else: - return out - -def normalize_pair(pair, noneval=None): - """Make sure the pair is a tuple that has exactly two entries. If we need - to fill anything in, we'll use noneval.""" - return (unpair(pair, False, noneval), - unpair(pair, True, noneval)) - - #### flags used to define fields' behavior #### @@ -93,7 +33,11 @@ class Enumeration(object): def __init__(self, *values): for value, equiv in zip(values, range(1, len(values)+1)): setattr(self, value, equiv) -packing = Enumeration('SLASHED', 'TUPLE') +# determine style of packing if any +packing = Enumeration('SLASHED', # pair delimited by / + 'TUPLE', # a python tuple of 2 items + 'DATE' # YYYY-MM-DD + ) class StorageStyle(object): """Parameterizes the storage behavior of a single field for a certain tag @@ -124,6 +68,86 @@ class StorageStyle(object): +#### dealing with packings #### + +class Packed(object): + """Makes a packed list of values subscriptable. To access the packed output + after making changes, use packed_thing.items.""" + + def __init__(self, items, packstyle, none_val=0, out_type=int): + """Create a Packed object for subscripting the packed values in items. + The items are packed using packstyle, which is a value from the + packing enum. none_val is returned from a request when no suitable + value is found in the items. Vales are converted to out_type before + they are returned.""" + self.items = items + self.packstyle = packstyle + self.none_val = none_val + self.out_type = out_type + + def __getitem__(self, index): + if not isinstance(index, int): + raise TypeError('index must be an integer') + + if self.items is None: + return self.none_val + + # transform from a string packing into a list we can index into + if self.packstyle == packing.SLASHED: + seq = unicode(self.items).split('/') + elif self.packstyle == packing.DATE: + seq = unicode(self.items).split('-') + elif self.packstyle == packing.TUPLE: + seq = self.items # tuple: items is already indexable + + try: + out = seq[index] + except: + out = None + + if out is None or out == self.none_val or out == '': + return self.out_type(self.none_val) + else: + return self.out_type(out) + + def __setitem__(self, index, value): + + if self.packstyle in (packing.SLASHED, packing.TUPLE): + # SLASHED and TUPLE are always two-item packings + length = 2 + else: + # DATE can have up to three fields + length = 3 + + # make a list of the items we'll pack + new_items = [] + for i in range(length): + if i == index: + next_item = value + else: + next_item = self[i] + new_items.append(next_item) + + if self.packstyle == packing.DATE: + # Truncate the items wherever we reach an invalid (none) entry. + # This prevents dates like 2008-00-05. + for i, item in enumerate(new_items): + if item == self.none_val or item is None: + del(new_items[i:]) # truncate + break + + if self.packstyle == packing.SLASHED: + self.items = '/'.join(map(unicode, new_items)) + elif self.packstyle == packing.DATE: + field_lengths = [4, 2, 2] # YYYY-MM-DD + elems = [] + for i, item in enumerate(new_items): + elems.append( ('%0' + str(field_lengths[i]) + 'i') % item ) + self.items = '-'.join(elems) + elif self.packstyle == packing.TUPLE: + self.items = new_items + + class MediaField(object): """A descriptor providing access to a particular (abstract) metadata @@ -217,11 +241,7 @@ class MediaField(object): style = self._style(obj) out = self._fetchdata(obj) - # deal with slashed and tuple storage - if style.packing: - if style.packing == packing.SLASHED: - out = fromslashed(out) - out = unpair(out, style.pack_pos, noneval=0) + if style.packing: out = Packed(out, style.packing)[style.pack_pos] # return the appropriate type if self.out_type == int: @@ -250,24 +270,11 @@ class MediaField(object): style = self._style(obj) if style.packing: - # fetch the existing value so we can preserve half of it - pair = self._fetchdata(obj) - if style.packing == packing.SLASHED: - pair = fromslashed(pair) - pair = normalize_pair(pair, noneval=0) - - # set the appropriate side of the pair - if style.pack_pos == 0: - pair = (val, pair[1]) - else: - pair = (pair[0], val) - - if style.packing == packing.SLASHED: - out = toslashed(pair) - else: - out = pair + p = Packed(self._fetchdata(obj), style.packing) + p[style.pack_pos] = val + out = p.items - else: # unicode, integer, or boolean + else: # unicode, integer, or boolean scalar out = val # deal with Nones according to abstract type if present @@ -367,9 +374,37 @@ class MediaFile(object): flac = StorageStyle('grouping') ) year = MediaField(out_type=int, - mp3 = StorageStyle('TDRC'), - mp4 = StorageStyle("\xa9day"), - flac = StorageStyle('date') + mp3 = StorageStyle('TDRC', + packing = packing.DATE, + pack_pos = 0), + mp4 = StorageStyle("\xa9day", + packing = packing.DATE, + pack_pos = 0), + flac = StorageStyle('date', + packing = packing.DATE, + pack_pos = 0) + ) + month = MediaField(out_type=int, + mp3 = StorageStyle('TDRC', + packing = packing.DATE, + pack_pos = 1), + mp4 = StorageStyle("\xa9day", + packing = packing.DATE, + pack_pos = 1), + flac = StorageStyle('date', + packing = packing.DATE, + pack_pos = 1) + ) + day = MediaField(out_type=int, + mp3 = StorageStyle('TDRC', + packing = packing.DATE, + pack_pos = 2), + mp4 = StorageStyle("\xa9day", + packing = packing.DATE, + pack_pos = 2), + flac = StorageStyle('date', + packing = packing.DATE, + pack_pos = 2) ) track = MediaField(out_type = int, mp3 = StorageStyle('TRCK', diff --git a/test/rsrc/test.blb b/test/rsrc/test.blb index 81b12afe8..f7c39bc76 100644 Binary files a/test/rsrc/test.blb and b/test/rsrc/test.blb differ diff --git a/test/test_db.py b/test/test_db.py index ad3076d48..4cc01725d 100755 --- a/test/test_db.py +++ b/test/test_db.py @@ -19,13 +19,15 @@ def item(lib=None): return beets.library.Item({ 'composer': u'the composer', 'grouping': u'the grouping', 'year': 1, - 'track': 2, - 'tracktotal': 3, - 'disc': 4, - 'disctotal': 5, + 'month': 2, + 'day': 3, + 'track': 4, + 'tracktotal': 5, + 'disc': 6, + 'disctotal': 7, 'lyrics': u'the lyrics', 'comments': u'the comments', - 'bpm': 6, + 'bpm': 8, 'comp': True, 'path': 'somepath', }, lib) diff --git a/test/test_mediafile.py b/test/test_mediafile.py index 24f91d34b..9e93a9315 100755 --- a/test/test_mediafile.py +++ b/test/test_mediafile.py @@ -78,6 +78,8 @@ correct_dicts = { 'composer': u'the composer', 'grouping': u'the grouping', 'year': 2001, + 'month': 0, + 'day': 0, 'track': 2, 'tracktotal': 3, 'disc': 4, @@ -96,6 +98,8 @@ correct_dicts = { 'composer': u'', 'grouping': u'', 'year': 0, + 'month': 0, + 'day': 0, 'track': 2, 'tracktotal': 0, 'disc': 4, @@ -114,6 +118,8 @@ correct_dicts = { 'composer': u'', 'grouping': u'', 'year': 0, + 'month': 0, + 'day': 0, 'track': 0, 'tracktotal': 0, 'disc': 0, @@ -133,6 +139,8 @@ correct_dicts = { 'composer': u'', 'grouping': u'', 'year': 0, + 'month': 0, + 'day': 0, 'track': 0, 'tracktotal': 0, 'disc': 0, @@ -141,15 +149,25 @@ correct_dicts = { 'comments': u'', 'bpm': 0, 'comp': False - } + }, + + # full release date + 'date': { + 'year': 1987, + 'month': 3, + 'day': 31 + }, } def suite_for_file(path, correct_dict): s = unittest.TestSuite() - for field in correct_dict.keys(): + for field in correct_dict: s.addTest(MakeReadingTest(path, correct_dict, field)()) - s.addTest(MakeWritingTest(path, correct_dict, field)()) + if not ( field == 'month' and correct_dict['year'] == 0 + or field == 'day' and correct_dict['month'] == 0): + # ensure that we don't test fields that can't be modified + s.addTest(MakeWritingTest(path, correct_dict, field)()) return s def suite(): @@ -158,14 +176,18 @@ def suite(): # General tests. for kind in ('m4a', 'mp3', 'flac'): for tagset in ('full', 'partial', 'min'): - path = 'rsrc' + os.sep + tagset + '.' + kind + path = os.path.join('rsrc', tagset + '.' + kind) correct_dict = correct_dicts[tagset] s.addTest(suite_for_file(path, correct_dict)) # Special test for missing ID3 tag. - s.addTest(suite_for_file('rsrc' + os.sep + 'empty.mp3', + s.addTest(suite_for_file(os.path.join('rsrc', 'empty.mp3'), correct_dicts['empty'])) + # Special test for advanced release date. + s.addTest(suite_for_file(os.path.join('rsrc', 'date.mp3'), + correct_dicts['date'])) + return s if __name__ == '__main__':