From d907dd6b40fd80042cd2f537e022e527f3b6c6a9 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Fri, 3 May 2013 18:02:06 -0300 Subject: [PATCH 1/5] Preliminary support for "date added" fields This isn't yet finished, it needs some input on how to organize the data, and actually where to implement the use of this data, but it already works in setting the date --- beets/library.py | 39 ++++++++++++++++++++++++++++++--------- test/test_db.py | 18 ++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/beets/library.py b/beets/library.py index 4962a44ad..34b8684f7 100644 --- a/beets/library.py +++ b/beets/library.py @@ -24,6 +24,7 @@ import unicodedata import threading import contextlib import traceback +import datetime from collections import defaultdict from unidecode import unidecode from beets.mediafile import MediaFile @@ -105,6 +106,10 @@ ITEM_FIELDS = [ ('bitdepth', 'int', False, True), ('channels', 'int', False, True), ('mtime', 'int', False, False), + + ('year_added', 'int', False, False), + ('month_added', 'int', False, False), + ('day_added', 'int', False, False), ] ITEM_KEYS_WRITABLE = [f[0] for f in ITEM_FIELDS if f[3] and f[2]] ITEM_KEYS_META = [f[0] for f in ITEM_FIELDS if f[3]] @@ -146,6 +151,10 @@ ALBUM_FIELDS = [ ('original_year', 'int', True), ('original_month', 'int', True), ('original_day', 'int', True), + + ('year_added', 'int', False), + ('month_added', 'int', False), + ('day_added', 'int', False), ] ALBUM_KEYS = [f[0] for f in ALBUM_FIELDS] ALBUM_KEYS_ITEM = [f[0] for f in ALBUM_FIELDS if f[2]] @@ -165,6 +174,11 @@ if not log.handlers: log.addHandler(logging.StreamHandler()) log.propagate = False # Don't propagate to root handler. +# Return a tuple for the current date (year, month, date) +def _date_tuple(): + date = datetime.datetime.now() + return (date.year, date.month, date.day) + # A little SQL utility. def _orelse(exp1, exp2): """Generates an SQLite expression that evaluates to exp1 if exp1 is @@ -708,9 +722,9 @@ class AnyFieldQuery(CollectionQuery): def match(self, item): for subq in self.subqueries: - if subq.match(item): - return True - return False + if subq.match(item): + return True + return False class MutableCollectionQuery(CollectionQuery): """A collection query whose subqueries may be modified after the @@ -1289,6 +1303,7 @@ class Library(BaseLibrary): # Item manipulation. def add(self, item, copy=False): + item.day_added, item.month_added, item.year_added = _date_tuple() item.library = self if copy: self.move(item, copy=True) @@ -1498,15 +1513,21 @@ class Library(BaseLibrary): from its items. The items are added to the database if they don't yet have an ID. Returns an Album object. """ + album_keys = ALBUM_KEYS_ITEM + ['day_added', 'month_added', 'year_added'] + # Set the metadata from the first item. - item_values = dict( + album_values = dict( (key, getattr(items[0], key)) for key in ALBUM_KEYS_ITEM) + # Manually set the date when the album was added, + # because the items don't yet have these + album_values['day_added'], album_values['month_added'], album_values['year_added'] = _date_tuple() + with self.transaction() as tx: sql = 'INSERT INTO albums (%s) VALUES (%s)' % \ - (', '.join(ALBUM_KEYS_ITEM), - ', '.join(['?'] * len(ALBUM_KEYS_ITEM))) - subvals = [item_values[key] for key in ALBUM_KEYS_ITEM] + (', '.join(album_keys), + ', '.join(['?'] * len(album_keys))) + subvals = [album_values[key] for key in album_keys] album_id = tx.mutate(sql, subvals) # Add the items to the library. @@ -1520,8 +1541,8 @@ class Library(BaseLibrary): # Construct the new Album object. record = {} for key in ALBUM_KEYS: - if key in ALBUM_KEYS_ITEM: - record[key] = item_values[key] + if key in album_keys: + record[key] = album_values[key] else: # Non-item fields default to None. record[key] = None diff --git a/test/test_db.py b/test/test_db.py index 4ee6ea419..f7e7f4bdb 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -967,6 +967,24 @@ class MtimeTest(unittest.TestCase): self.i.read() self.assertGreaterEqual(self.i.mtime, self._mtime()) +class AtimeTest(unittest.TestCase): + def setUp(self): + self.lib = beets.library.Library(':memory:') + + def test_atime_for_album(self): + self.track = item() + self.album = self.lib.add_album((self.track,)) + self.assertGreater(self.album.day_added, 0) + self.assertGreater(self.album.month_added, 0) + self.assertGreater(self.album.year_added, 0) + + def test_atime_for_singleton(self): + self.singleton = item() + self.lib.add(self.singleton) + self.assertGreater(self.singleton.day_added, 0) + self.assertGreater(self.singleton.month_added, 0) + self.assertGreater(self.singleton.year_added, 0) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From 0a631bcda20912260e032d4aa0513d218a1ecca5 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Sat, 4 May 2013 15:05:35 -0300 Subject: [PATCH 2/5] Using time.time() to store the import time --- beets/library.py | 40 +++++++++++++++------------------------- test/test_db.py | 17 +++++++---------- 2 files changed, 22 insertions(+), 35 deletions(-) diff --git a/beets/library.py b/beets/library.py index 34b8684f7..399048612 100644 --- a/beets/library.py +++ b/beets/library.py @@ -24,7 +24,7 @@ import unicodedata import threading import contextlib import traceback -import datetime +import time from collections import defaultdict from unidecode import unidecode from beets.mediafile import MediaFile @@ -106,10 +106,7 @@ ITEM_FIELDS = [ ('bitdepth', 'int', False, True), ('channels', 'int', False, True), ('mtime', 'int', False, False), - - ('year_added', 'int', False, False), - ('month_added', 'int', False, False), - ('day_added', 'int', False, False), + ('itime', 'int', False, False), ] ITEM_KEYS_WRITABLE = [f[0] for f in ITEM_FIELDS if f[3] and f[2]] ITEM_KEYS_META = [f[0] for f in ITEM_FIELDS if f[3]] @@ -152,9 +149,7 @@ ALBUM_FIELDS = [ ('original_month', 'int', True), ('original_day', 'int', True), - ('year_added', 'int', False), - ('month_added', 'int', False), - ('day_added', 'int', False), + ('itime', 'int', False), ] ALBUM_KEYS = [f[0] for f in ALBUM_FIELDS] ALBUM_KEYS_ITEM = [f[0] for f in ALBUM_FIELDS if f[2]] @@ -174,11 +169,6 @@ if not log.handlers: log.addHandler(logging.StreamHandler()) log.propagate = False # Don't propagate to root handler. -# Return a tuple for the current date (year, month, date) -def _date_tuple(): - date = datetime.datetime.now() - return (date.year, date.month, date.day) - # A little SQL utility. def _orelse(exp1, exp2): """Generates an SQLite expression that evaluates to exp1 if exp1 is @@ -1303,10 +1293,10 @@ class Library(BaseLibrary): # Item manipulation. def add(self, item, copy=False): - item.day_added, item.month_added, item.year_added = _date_tuple() - item.library = self - if copy: - self.move(item, copy=True) + item.itime = time.time() + item.library = self + if copy: + self.move(item, copy=True) # Build essential parts of query. columns = ','.join([key for key in ITEM_KEYS if key != 'id']) @@ -1510,21 +1500,21 @@ class Library(BaseLibrary): def add_album(self, items): """Create a new album in the database with metadata derived - from its items. The items are added to the database if they - don't yet have an ID. Returns an Album object. - """ - album_keys = ALBUM_KEYS_ITEM + ['day_added', 'month_added', 'year_added'] + from its items. The items are added to the database if they + don't yet have an ID. Returns an Album object. + """ + album_keys = ALBUM_KEYS_ITEM + ['itime'] - # Set the metadata from the first item. + # Set the metadata from the first item. album_values = dict( (key, getattr(items[0], key)) for key in ALBUM_KEYS_ITEM) # Manually set the date when the album was added, # because the items don't yet have these - album_values['day_added'], album_values['month_added'], album_values['year_added'] = _date_tuple() + album_values['itime'] = time.time() - with self.transaction() as tx: - sql = 'INSERT INTO albums (%s) VALUES (%s)' % \ + with self.transaction() as tx: + sql = 'INSERT INTO albums (%s) VALUES (%s)' % \ (', '.join(album_keys), ', '.join(['?'] * len(album_keys))) subvals = [album_values[key] for key in album_keys] diff --git a/test/test_db.py b/test/test_db.py index f7e7f4bdb..f6c94bba7 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -964,26 +964,23 @@ class MtimeTest(unittest.TestCase): def test_mtime_up_to_date_after_read(self): self.i.title = 'something else' - self.i.read() - self.assertGreaterEqual(self.i.mtime, self._mtime()) + self.i.read() + self.assertGreaterEqual(self.i.mtime, self._mtime()) -class AtimeTest(unittest.TestCase): +class ImportTimeTest(unittest.TestCase): def setUp(self): self.lib = beets.library.Library(':memory:') - def test_atime_for_album(self): + def test_itime_for_album(self): self.track = item() self.album = self.lib.add_album((self.track,)) - self.assertGreater(self.album.day_added, 0) - self.assertGreater(self.album.month_added, 0) - self.assertGreater(self.album.year_added, 0) + self.assertGreater(self.album.itime, 0) + self.assertGreater(self.track.itime, 0) def test_atime_for_singleton(self): self.singleton = item() self.lib.add(self.singleton) - self.assertGreater(self.singleton.day_added, 0) - self.assertGreater(self.singleton.month_added, 0) - self.assertGreater(self.singleton.year_added, 0) + self.assertGreater(self.singleton.itime, 0) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From 5c31d3ac15e4ea74b11a1c7bc3bfddc3455336dd Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Sat, 4 May 2013 15:40:46 -0300 Subject: [PATCH 3/5] Formatting the import time in the view and paths Added the %format{} template function to output the time to any format supported by time.strftime() --- beets/library.py | 16 ++++++++++++++++ docs/reference/pathformat.rst | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/beets/library.py b/beets/library.py index 399048612..9721d5e6b 100644 --- a/beets/library.py +++ b/beets/library.py @@ -37,6 +37,10 @@ import beets MAX_FILENAME_LENGTH = 200 +# This is the default format when printing the import time +# of an object. This needs to be a format accepted by time.strftime() +ITIME_FORMAT = '%Y-%m-%d %H:%M:%S' + # Fields in the "items" database table; all the metadata available for # items in the library. These are used directly in SQL; they are # vulnerable to injection if accessible to the user. @@ -398,6 +402,9 @@ class Item(object): if not sanitize: mapping['path'] = displayable_path(self.path) + # Convert the import time to human readable + mapping['itime'] = time.strftime(ITIME_FORMAT, time.localtime(getattr(self, 'itime'))) + # Use the album artist if the track artist is not set and # vice-versa. if not mapping['artist']: @@ -1741,6 +1748,9 @@ class Album(BaseAlbum): mapping['artpath'] = displayable_path(mapping['artpath']) mapping['path'] = displayable_path(self.item_dir()) + # Convert the import time to human readable format + mapping['itime'] = time.strftime(ITIME_FORMAT, time.localtime(mapping['itime'])) + # Get template functions. funcs = DefaultTemplateFunctions().functions() funcs.update(plugins.template_funcs()) @@ -1829,6 +1839,12 @@ class DefaultTemplateFunctions(object): """ return unidecode(s) + @staticmethod + def tmpl_format(s, format): + """Format the import time to any format according to time.strfime() + """ + return time.strftime(format, time.strptime(s, ITIME_FORMAT)) + def tmpl_aunique(self, keys=None, disam=None): """Generate a string that is guaranteed to be unique among all albums in the library who share the same set of keys. A fields diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index 13661d9d8..2474c97d5 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -56,7 +56,7 @@ track's artists. These functions are built in to beets: -* ``%lower{text}``: Convert ``text`` to lowercase. +* ``%lower{text}``: Convert ``text`` to lowercase. * ``%upper{text}``: Convert ``text`` to UPPERCASE. * ``%title{text}``: Convert ``text`` to Title Case. * ``%left{text,n}``: Return the first ``n`` characters of ``text``. @@ -70,8 +70,12 @@ These functions are built in to beets: `unidecode module`_. * ``%aunique{identifiers,disambiguators}``: Provides a unique string to disambiguate similar albums in the database. See :ref:`aunique`, below. +* ``%format{date_time,format}``: Return the date and time in any format accepted + by the `time.strfime() method`_. Should probably be used together with the + ``itime`` field (import time). .. _unidecode module: http://pypi.python.org/pypi/Unidecode +.. _time.strftime() method: http://docs.python.org/2/library/time.html#time.strftime Plugins can extend beets with more template functions (see :ref:`writing-plugins`). From 4d5773c195c3a6525f5a5ee4ab9e1765b94a819b Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Thu, 9 May 2013 23:10:49 -0300 Subject: [PATCH 4/5] Support for date/time fields --- beets/library.py | 4 ++-- beets/ui/commands.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/beets/library.py b/beets/library.py index 9721d5e6b..acebf7f5b 100644 --- a/beets/library.py +++ b/beets/library.py @@ -110,7 +110,7 @@ ITEM_FIELDS = [ ('bitdepth', 'int', False, True), ('channels', 'int', False, True), ('mtime', 'int', False, False), - ('itime', 'int', False, False), + ('itime', 'datetime', False, False), ] ITEM_KEYS_WRITABLE = [f[0] for f in ITEM_FIELDS if f[3] and f[2]] ITEM_KEYS_META = [f[0] for f in ITEM_FIELDS if f[3]] @@ -153,7 +153,7 @@ ALBUM_FIELDS = [ ('original_month', 'int', True), ('original_day', 'int', True), - ('itime', 'int', False), + ('itime', 'datetime', False), ] ALBUM_KEYS = [f[0] for f in ALBUM_FIELDS] ALBUM_KEYS_ITEM = [f[0] for f in ALBUM_FIELDS if f[2]] diff --git a/beets/ui/commands.py b/beets/ui/commands.py index be5cd811c..6a2669f49 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -993,6 +993,7 @@ CONSTRUCTOR_MAPPING = { 'int': int, 'bool': util.str2bool, 'real': float, + 'datetime': lambda v: int(time.mktime(time.strptime(v, library.ITIME_FORMAT))), } # Convert a string (from user input) to the correct Python type @@ -1013,7 +1014,7 @@ def _convert_type(key, value, album=False): def modify_items(lib, mods, query, write, move, album, confirm): """Modifies matching items according to key=value assignments.""" # Parse key=value specifications into a dictionary. - allowed_keys = library.ALBUM_KEYS if album else library.ITEM_KEYS_WRITABLE + allowed_keys = library.ALBUM_KEYS if album else library.ITEM_KEYS_WRITABLE + ['itime'] fsets = {} for mod in mods: key, value = mod.split('=', 1) From c7c2b266cdbf057326a310a0ed56b1671e47bb31 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Sat, 11 May 2013 10:58:19 -0300 Subject: [PATCH 5/5] correcting identation --- beets/library.py | 62 ++++++++++++++++++++++++------------------------ test/test_db.py | 20 ++++++++-------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/beets/library.py b/beets/library.py index acebf7f5b..47c42f398 100644 --- a/beets/library.py +++ b/beets/library.py @@ -148,7 +148,7 @@ ALBUM_FIELDS = [ ('media', 'text', True), ('albumdisambig', 'text', True), ('rg_album_gain', 'real', True), - ('rg_album_peak', 'real', True), + ('rg_album_peak', 'real', True),® ('original_year', 'int', True), ('original_month', 'int', True), ('original_day', 'int', True), @@ -402,8 +402,8 @@ class Item(object): if not sanitize: mapping['path'] = displayable_path(self.path) - # Convert the import time to human readable - mapping['itime'] = time.strftime(ITIME_FORMAT, time.localtime(getattr(self, 'itime'))) + # Convert the import time to human readable + mapping['itime'] = time.strftime(ITIME_FORMAT, time.localtime(getattr(self, 'itime'))) # Use the album artist if the track artist is not set and # vice-versa. @@ -719,9 +719,9 @@ class AnyFieldQuery(CollectionQuery): def match(self, item): for subq in self.subqueries: - if subq.match(item): - return True - return False + if subq.match(item): + return True + return False class MutableCollectionQuery(CollectionQuery): """A collection query whose subqueries may be modified after the @@ -1300,10 +1300,10 @@ class Library(BaseLibrary): # Item manipulation. def add(self, item, copy=False): - item.itime = time.time() - item.library = self - if copy: - self.move(item, copy=True) + item.itime = time.time() + item.library = self + if copy: + self.move(item, copy=True) # Build essential parts of query. columns = ','.join([key for key in ITEM_KEYS if key != 'id']) @@ -1507,24 +1507,24 @@ class Library(BaseLibrary): def add_album(self, items): """Create a new album in the database with metadata derived - from its items. The items are added to the database if they - don't yet have an ID. Returns an Album object. - """ - album_keys = ALBUM_KEYS_ITEM + ['itime'] + from its items. The items are added to the database if they + don't yet have an ID. Returns an Album object. + """ + album_keys = ALBUM_KEYS_ITEM + ['itime'] - # Set the metadata from the first item. - album_values = dict( + # Set the metadata from the first item. + album_values = dict( (key, getattr(items[0], key)) for key in ALBUM_KEYS_ITEM) - # Manually set the date when the album was added, - # because the items don't yet have these - album_values['itime'] = time.time() + # Manually set the date when the album was added, + # because the items don't yet have these + album_values['itime'] = time.time() - with self.transaction() as tx: - sql = 'INSERT INTO albums (%s) VALUES (%s)' % \ - (', '.join(album_keys), - ', '.join(['?'] * len(album_keys))) - subvals = [album_values[key] for key in album_keys] + with self.transaction() as tx: + sql = 'INSERT INTO albums (%s) VALUES (%s)' % \ + (', '.join(album_keys), + ', '.join(['?'] * len(album_keys))) + subvals = [album_values[key] for key in album_keys] album_id = tx.mutate(sql, subvals) # Add the items to the library. @@ -1538,8 +1538,8 @@ class Library(BaseLibrary): # Construct the new Album object. record = {} for key in ALBUM_KEYS: - if key in album_keys: - record[key] = album_values[key] + if key in album_keys: + record[key] = album_values[key] else: # Non-item fields default to None. record[key] = None @@ -1748,8 +1748,8 @@ class Album(BaseAlbum): mapping['artpath'] = displayable_path(mapping['artpath']) mapping['path'] = displayable_path(self.item_dir()) - # Convert the import time to human readable format - mapping['itime'] = time.strftime(ITIME_FORMAT, time.localtime(mapping['itime'])) + # Convert the import time to human readable format + mapping['itime'] = time.strftime(ITIME_FORMAT, time.localtime(mapping['itime'])) # Get template functions. funcs = DefaultTemplateFunctions().functions() @@ -1841,9 +1841,9 @@ class DefaultTemplateFunctions(object): @staticmethod def tmpl_format(s, format): - """Format the import time to any format according to time.strfime() - """ - return time.strftime(format, time.strptime(s, ITIME_FORMAT)) + """Format the import time to any format according to time.strfime() + """ + return time.strftime(format, time.strptime(s, ITIME_FORMAT)) def tmpl_aunique(self, keys=None, disam=None): """Generate a string that is guaranteed to be unique among all diff --git a/test/test_db.py b/test/test_db.py index f6c94bba7..5099cb83e 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -964,23 +964,23 @@ class MtimeTest(unittest.TestCase): def test_mtime_up_to_date_after_read(self): self.i.title = 'something else' - self.i.read() - self.assertGreaterEqual(self.i.mtime, self._mtime()) + self.i.read() + self.assertGreaterEqual(self.i.mtime, self._mtime()) class ImportTimeTest(unittest.TestCase): def setUp(self): - self.lib = beets.library.Library(':memory:') + self.lib = beets.library.Library(':memory:') def test_itime_for_album(self): - self.track = item() - self.album = self.lib.add_album((self.track,)) - self.assertGreater(self.album.itime, 0) - self.assertGreater(self.track.itime, 0) + self.track = item() + self.album = self.lib.add_album((self.track,)) + self.assertGreater(self.album.itime, 0) + self.assertGreater(self.track.itime, 0) def test_atime_for_singleton(self): - self.singleton = item() - self.lib.add(self.singleton) - self.assertGreater(self.singleton.itime, 0) + self.singleton = item() + self.lib.add(self.singleton) + self.assertGreater(self.singleton.itime, 0) def suite(): return unittest.TestLoader().loadTestsFromName(__name__)