From 210d4f3af381a51657bb526348c55f72470d8127 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 4 Apr 2010 15:20:40 -0700 Subject: [PATCH 01/11] playing around with very basic iPod support --HG-- branch : device --- beets/device.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++ bts | 15 +++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 beets/device.py diff --git a/beets/device.py b/beets/device.py new file mode 100644 index 000000000..c54aa979c --- /dev/null +++ b/beets/device.py @@ -0,0 +1,51 @@ +import gpod +import os +import sys +import socket +import locale + +def start_sync(pod): + # Make sure we have a version of libgpod with these + # iPhone-specific functions. + if hasattr(gpod, 'itdb_start_sync'): + gpod.itdb_start_sync(pod._itdb) +def stop_sync(pod): + if hasattr(gpod, 'itdb_stop_sync'): + gpod.itdb_stop_sync(pod._itdb) + +def pod_path(name): + #FIXME: os.path.expanduser('~') to get $HOME is hacky! + return os.path.join(os.path.expanduser('~'), '.gvfs', name) + +def get_pod(path): + return gpod.Database(path) + +def add(pod, items): + def cbk(db, track, it, total): + print 'copying', track + start_sync(pod) + try: + for item in items: + track = pod.new_Track() + track['userdata'] = { + 'transferred': 0, + 'hostname': socket.gethostname(), + 'charset': locale.getpreferredencoding(), + 'pc_mtime': os.stat(item.path).st_mtime, + } + track._set_userdata_utf8('filename', item.path.encode()) + track['artist'] = item.artist + track['title'] = item.title + track['BPM'] = item.bpm + track['genre'] = item.genre + track['album'] = item.album + track['cd_nr'] = item.disc + track['cds'] = item.disctotal + track['track_nr'] = item.track + track['tracks'] = item.tracktotal + track['tracklen'] = int(item.length * 1000) + pod.copy_delayed_files(cbk) + finally: + pod.close() + stop_sync(pod) + diff --git a/bts b/bts index 8205ad563..26168bbe6 100755 --- a/bts +++ b/bts @@ -176,6 +176,21 @@ class BeetsApp(cmdln.Cmdln): from beets.player.bpd import Server Server(self.lib, host, int(port), password).run() + def do_dadd(self, subcmd, opts, name, *criteria): + """${cmd_name}: add files to a device + + ${cmd_usage} + ${cmd_option_list} + """ + q = ' '.join(criteria) + if not q.strip(): q = None + items = self.lib.items(query=q) + + from beets import device + path = device.pod_path(name) + pod = device.get_pod(path) + + device.add(pod, items) if __name__ == '__main__': app = BeetsApp() From 4258474da80937be73533fd07b04a3166b5cdc71 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 5 Apr 2010 21:07:22 -0700 Subject: [PATCH 02/11] reorganize gpod communication into PodLibrary class --HG-- branch : device --- beets/device.py | 107 +++++++++++++++++++++++++++++------------------- bts | 6 +-- 2 files changed, 68 insertions(+), 45 deletions(-) diff --git a/beets/device.py b/beets/device.py index c54aa979c..bb61114c5 100644 --- a/beets/device.py +++ b/beets/device.py @@ -3,49 +3,74 @@ import os import sys import socket import locale +from beets.library import Library, Item -def start_sync(pod): - # Make sure we have a version of libgpod with these - # iPhone-specific functions. - if hasattr(gpod, 'itdb_start_sync'): - gpod.itdb_start_sync(pod._itdb) -def stop_sync(pod): - if hasattr(gpod, 'itdb_stop_sync'): - gpod.itdb_stop_sync(pod._itdb) +FIELD_MAP = { + 'artist': 'artist', + 'title': 'title', + 'BPM': 'bpm', + 'genre': 'genre', + 'album': 'album', + 'cd_nr': 'disc', + 'cds': 'disctotal', + 'track_nr': 'track', + 'tracks': 'tracktotal', +} -def pod_path(name): - #FIXME: os.path.expanduser('~') to get $HOME is hacky! - return os.path.join(os.path.expanduser('~'), '.gvfs', name) +class PodLibrary(Library): + def __init__(self, path): + self.db = gpod.Database(path) -def get_pod(path): - return gpod.Database(path) + @classmethod + def by_name(cls, name): + return cls(os.path.join(os.path.expanduser('~'), '.gvfs', name)) -def add(pod, items): - def cbk(db, track, it, total): - print 'copying', track - start_sync(pod) - try: - for item in items: - track = pod.new_Track() - track['userdata'] = { - 'transferred': 0, - 'hostname': socket.gethostname(), - 'charset': locale.getpreferredencoding(), - 'pc_mtime': os.stat(item.path).st_mtime, - } - track._set_userdata_utf8('filename', item.path.encode()) - track['artist'] = item.artist - track['title'] = item.title - track['BPM'] = item.bpm - track['genre'] = item.genre - track['album'] = item.album - track['cd_nr'] = item.disc - track['cds'] = item.disctotal - track['track_nr'] = item.track - track['tracks'] = item.tracktotal - track['tracklen'] = int(item.length * 1000) - pod.copy_delayed_files(cbk) - finally: - pod.close() - stop_sync(pod) + def _start_sync(self): + # Make sure we have a version of libgpod with these + # iPhone-specific functions. + if hasattr(gpod, 'itdb_start_sync'): + gpod.itdb_start_sync(self.db._itdb) + + def _stop_sync(self): + if hasattr(gpod, 'itdb_stop_sync'): + gpod.itdb_stop_sync(self.db._itdb) + + def add_items(self, items): + self._start_sync() + try: + for item in items: + track = self.db.new_Track() + track['userdata'] = { + 'transferred': 0, + 'hostname': socket.gethostname(), + 'charset': locale.getpreferredencoding(), + 'pc_mtime': os.stat(item.path).st_mtime, + } + track._set_userdata_utf8('filename', item.path.encode()) + for dname, bname in FIELD_MAP.items(): + track[dname] = getattr(item, bname) + track['tracklen'] = int(item.length * 1000) + self.db.copy_delayed_files() + finally: + self.db.close() + self._stop_sync() + + def add(self, path): + raise NotImplementedError + + def get(self, query=None): + raise NotImplementedError + + def save(self): + raise NotImplementedError + + # Browsing convenience. + def artists(self, query=None): + raise NotImplementedError + + def albums(self, artist=None, query=None): + raise NotImplementedError + + def items(self, artist=None, album=None, title=None, query=None): + raise NotImplementedError diff --git a/bts b/bts index 26168bbe6..8b94ca60f 100755 --- a/bts +++ b/bts @@ -187,10 +187,8 @@ class BeetsApp(cmdln.Cmdln): items = self.lib.items(query=q) from beets import device - path = device.pod_path(name) - pod = device.get_pod(path) - - device.add(pod, items) + pod = device.PodLibrary.by_name(name) + pod.add_items(items) if __name__ == '__main__': app = BeetsApp() From 8e27693d2f5204ea6943db9d27500c0696fdc778 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 5 Apr 2010 21:50:40 -0700 Subject: [PATCH 03/11] create BaseLibrary class, which Library and PodLibrary extend --HG-- branch : device --- beets/device.py | 4 +- beets/library.py | 168 +++++++++++++++++++++++++++++++---------------- 2 files changed, 115 insertions(+), 57 deletions(-) diff --git a/beets/device.py b/beets/device.py index bb61114c5..73d98fe3f 100644 --- a/beets/device.py +++ b/beets/device.py @@ -3,7 +3,7 @@ import os import sys import socket import locale -from beets.library import Library, Item +from beets.library import BaseLibrary, Item FIELD_MAP = { 'artist': 'artist', @@ -17,7 +17,7 @@ FIELD_MAP = { 'tracks': 'tracktotal', } -class PodLibrary(Library): +class PodLibrary(BaseLibrary): def __init__(self, path): self.db = gpod.Database(path) diff --git a/beets/library.py b/beets/library.py index 0e5c17319..11d9eef47 100644 --- a/beets/library.py +++ b/beets/library.py @@ -592,35 +592,13 @@ class ResultIterator(object): +class BaseLibrary(object): + """Base class for music libraries.""" + def __init__(self): + raise NotImplementedError - -class Library(object): - def __init__(self, path='library.blb', - directory='~/Music', - path_format='$artist/$album/$track $title'): - self.path = path - self.directory = directory - self.path_format = path_format - - self.conn = sqlite3.connect(self.path) - self.conn.row_factory = sqlite3.Row - # this way we can access our SELECT results like dictionaries - - self._setup() - - def _setup(self): - """Set up the schema of the library file.""" - - setup_sql = 'CREATE TABLE IF NOT EXISTS items (' - setup_sql += ', '.join([' '.join(f) for f in item_fields]) - setup_sql += ');' - - self.conn.executescript(setup_sql) - self.conn.commit() - - ### helpers ### @classmethod @@ -636,30 +614,120 @@ class Library(object): return val elif not isinstance(query, Query): raise ValueError('query must be None or have type Query or str') - - - #### main interface #### - - def add(self, path, copy=False): - """Add a file to the library or recursively search a directory and add - all its contents. If copy is True, copy files to their destination in - the library directory while adding. + + ### basic operations ### + + def add_items(self, items, copy=False): #FIXME rename to "add", copy default to true + """Add each item to the library. If copy, then each item is + copied to the destination location before it is added. """ - - for f in _walk_files(path): - try: - i = Item.from_path(_normpath(f), self) - if copy: - i.move(copy=True) - i.add() - except FileTypeError: - log.warn(f + ' of unknown type, skipping') - + raise NotImplementedError + def get(self, query=None): - """Returns a ResultIterator to the items matching query, which may be + """Returns a sequence of the items matching query, which may be None (match the entire library), a Query object, or a query string. """ + raise NotImplementedError + + def save(self): + """Ensure that the library is consistent on disk. A no-op by + default. + """ + pass + + + ### browsing operations ### + # Naive implementations are provided, but these methods should be + # overridden if a better implementation exists. + + def artists(self, query=None): + """Returns a sorted sequence of artists in the database, possibly + filtered by a query (in the same sense as get()). + """ + out = set() + for item in self.get(query): + out.add(item.artist) + return sorted(out) + + def albums(self, artist=None, query=None): + """Returns a sorted list of (artist, album) pairs, possibly filtered + by an artist name or an arbitrary query. + """ + out = set() + for item in self.get(query): + if artist is None or item.artist == artist: + out.add((item.artist, item.album)) + return sorted(out) + + def items(self, artist=None, album=None, title=None, query=None): + """Returns a sequence of the items matching the given artist, + album, title, and query (if present). Sorts in such a way as to + group albums appropriately. + """ + out = [] + for item in self.get(query): + if (artist is None or item.artist == artist) and \ + (album is None or item.album == album) and \ + (title is None or item.title == title): + out.append(item) + + # Sort by: artist, album, disc, track. + def compare(a, b): + return cmp(a.artist, b.artist) or \ + cmp(a.album, b.album) or \ + cmp(a.disc, b.disc) or \ + cmp(a.track, b.track) + return sorted(out, compare) + + + ### convenience methods ### + + def add(self, path, copy=False): #FIXME change name to add_path() + items = [] + for f in _walk_files(path): + try: + items.append(Item.from_path(_normpath(f), self)) + except FileTypeError: + log.warn(f + ' of unknown type, skipping') + self.add_items(items, copy) + +class Library(BaseLibrary): + """A music library using an SQLite database as a metadata store.""" + def __init__(self, path='library.blb', + directory='~/Music', + path_format='$artist/$album/$track $title'): + self.path = path + self.directory = directory + self.path_format = path_format + + self.conn = sqlite3.connect(self.path) + self.conn.row_factory = sqlite3.Row + # this way we can access our SELECT results like dictionaries + + self._setup() + + def _setup(self): + """Set up the schema of the library file.""" + setup_sql = 'CREATE TABLE IF NOT EXISTS items (' + setup_sql += ', '.join([' '.join(f) for f in item_fields]) + setup_sql += ');' + + self.conn.executescript(setup_sql) + self.conn.commit() + + + #### main interface #### + + def add_items(self, items, copy=False): + for i in items: + #FIXME make a deep copy of the item? + i.library = self + if copy: + i.move(copy=True) + i.add() + + def get(self, query=None): return self._get_query(query).execute(self) def save(self): @@ -671,9 +739,6 @@ class Library(object): ### browsing ### def artists(self, query=None): - """Returns a list of artists in the database, possibly filtered by a - query (in the same sense as get()). - """ where, subvals = self._get_query(query).clause() sql = "SELECT DISTINCT artist FROM items " + \ "WHERE " + where + \ @@ -682,9 +747,6 @@ class Library(object): return [res[0] for res in c.fetchall()] def albums(self, artist=None, query=None): - """Returns a list of (artist, album) pairs, possibly filtered by an - artist name or an arbitrary query. - """ query = self._get_query(query) if artist is not None: # "Add" the artist to the query. @@ -697,10 +759,6 @@ class Library(object): return [(res[0], res[1]) for res in c.fetchall()] def items(self, artist=None, album=None, title=None, query=None): - """Returns a ResultIterator over the items matching the given artist, - album, title, and query (if present). Sorts in such a way as to group - albums appropriately. - """ queries = [self._get_query(query)] if artist is not None: queries.append(MatchQuery('artist', artist)) From 83d661152e8b1c9f86289fe836d3ad831d04acdb Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 5 Apr 2010 23:09:04 -0700 Subject: [PATCH 04/11] moved library logic to the library: load, store, add, remove --HG-- branch : device --- beets/library.py | 162 +++++++++++++++++++++++++++-------------------- 1 file changed, 95 insertions(+), 67 deletions(-) diff --git a/beets/library.py b/beets/library.py index 11d9eef47..e63f9dfed 100644 --- a/beets/library.py +++ b/beets/library.py @@ -193,19 +193,11 @@ class Item(object): def load(self, load_id=None): """Refresh the item's metadata from the library database. If fetch_id - is not specified, use the current item's id. + is not specified, use the item's current id. """ if not self.library: - raise LibraryError('no library to load from') - - if load_id is None: - load_id = self.id - - c = self.library.conn.execute( - 'SELECT * FROM items WHERE id=?', (load_id,) ) - self._fill_record(c.fetchone()) - self._clear_dirty() - c.close() + raise LibraryError('no library to store to') + self.library.load(self, load_id) def store(self, store_id=None, store_all=False): """Save the item's metadata into the library database. If store_id is @@ -214,32 +206,10 @@ class Item(object): """ if not self.library: raise LibraryError('no library to store to') - - if store_id is None: - store_id = self.id - - # build assignments for query - assignments = '' - subvars = [] - for key in item_keys: - if (key != 'id') and (self.dirty[key] or store_all): - assignments += key + '=?,' - subvars.append(getattr(self, key)) - - if not assignments: - # nothing to store (i.e., nothing was dirty) - return - - assignments = assignments[:-1] # knock off last , - - # finish the query - query = 'UPDATE items SET ' + assignments + ' WHERE id=?' - subvars.append(self.id) - - self.library.conn.execute(query, subvars) + self.library.store(self, store_id, store_all) self._clear_dirty() - def add(self, library=None): + def add(self, library=None, copy=False): """Add the item as a new object to the library database. The id field will be updated; the new id is returned. If library is specified, set the item's library before adding. @@ -248,31 +218,15 @@ class Item(object): self.library = library if not self.library: raise LibraryError('no library to add to') + self.library.add_item(self, copy) + return self.id - # build essential parts of query - columns = ','.join([key for key in item_keys if key != 'id']) - values = ','.join( ['?'] * (len(item_keys)-1) ) - subvars = [] - for key in item_keys: - if key != 'id': - subvars.append(getattr(self, key)) - - # issue query - c = self.library.conn.cursor() - query = 'INSERT INTO items (' + columns + ') VALUES (' + values + ')' - c.execute(query, subvars) - new_id = c.lastrowid - c.close() - - self._clear_dirty() - self.id = new_id - return new_id - def remove(self): """Removes the item from the database (leaving the file on disk). """ - self.library.conn.execute('DELETE FROM items WHERE id=?', - (self.id,) ) + if not self.library: + raise LibraryError('no library to remove from') + self.library.remove(self) #### interaction with files' metadata #### @@ -618,8 +572,9 @@ class BaseLibrary(object): ### basic operations ### - def add_items(self, items, copy=False): #FIXME rename to "add", copy default to true - """Add each item to the library. If copy, then each item is + def add_item(self, item, copy=False): #FIXME rename to "add", copy default to true + """Add the item as a new object to the library database. The id field + will be updated; the new id is returned. If copy, then each item is copied to the destination location before it is added. """ raise NotImplementedError @@ -636,6 +591,24 @@ class BaseLibrary(object): """ pass + def load(self, item, load_id=None): + """Refresh the item's metadata from the library database. If fetch_id + is not specified, use the item's current id. + """ + raise NotImplementedError + + def store(self, item, store_id=None, store_all=False): + """Save the item's metadata into the library database. If store_id is + specified, use it instead of the item's current id. If store_all is + true, save the entire record instead of just the dirty fields. + """ + raise NotImplementedError + + def remove(self, item): + """Removes the item from the database (leaving the file on disk). + """ + raise NotImplementedError + ### browsing operations ### # Naive implementations are provided, but these methods should be @@ -687,10 +660,11 @@ class BaseLibrary(object): items = [] for f in _walk_files(path): try: - items.append(Item.from_path(_normpath(f), self)) + item = Item.from_path(_normpath(f), self) except FileTypeError: log.warn(f + ' of unknown type, skipping') - self.add_items(items, copy) + self.add_item(item, copy) + class Library(BaseLibrary): """A music library using an SQLite database as a metadata store.""" @@ -719,13 +693,30 @@ class Library(BaseLibrary): #### main interface #### - def add_items(self, items, copy=False): - for i in items: - #FIXME make a deep copy of the item? - i.library = self - if copy: - i.move(copy=True) - i.add() + def add_item(self, item, copy=False): + #FIXME make a deep copy of the item? + item.library = self + if copy: + item.move(copy=True) + + # build essential parts of query + columns = ','.join([key for key in item_keys if key != 'id']) + values = ','.join( ['?'] * (len(item_keys)-1) ) + subvars = [] + for key in item_keys: + if key != 'id': + subvars.append(getattr(item, key)) + + # issue query + c = self.conn.cursor() + query = 'INSERT INTO items (' + columns + ') VALUES (' + values + ')' + c.execute(query, subvars) + new_id = c.lastrowid + c.close() + + item._clear_dirty() + item.id = new_id + return new_id def get(self, query=None): return self._get_query(query).execute(self) @@ -735,6 +726,43 @@ class Library(BaseLibrary): """ self.conn.commit() + def load(self, item, load_id=None): + if load_id is None: + load_id = item.id + + c = self.conn.execute( + 'SELECT * FROM items WHERE id=?', (load_id,) ) + item._fill_record(c.fetchone()) + item._clear_dirty() + c.close() + + def store(self, item, store_id=None, store_all=False): + if store_id is None: + store_id = item.id + + # build assignments for query + assignments = '' + subvars = [] + for key in item_keys: + if (key != 'id') and (item.dirty[key] or store_all): + assignments += key + '=?,' + subvars.append(getattr(item, key)) + + if not assignments: + # nothing to store (i.e., nothing was dirty) + return + + assignments = assignments[:-1] # knock off last , + + # finish the query + query = 'UPDATE items SET ' + assignments + ' WHERE id=?' + subvars.append(item.id) + + self.conn.execute(query, subvars) + + def remove(self, item): + self.conn.execute('DELETE FROM items WHERE id=?', (item.id,)) + ### browsing ### From d3d485195ce73bf2b75411178f80658334402a86 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 6 Apr 2010 10:07:58 -0700 Subject: [PATCH 05/11] move destination calculation to Library from Item --HG-- branch : device --- beets/library.py | 85 ++++++++++++++++++++++++------------------------ test/test_db.py | 19 ++++++----- 2 files changed, 54 insertions(+), 50 deletions(-) diff --git a/beets/library.py b/beets/library.py index e63f9dfed..ff1b46249 100644 --- a/beets/library.py +++ b/beets/library.py @@ -254,46 +254,6 @@ class Item(object): #### dealing with files themselves #### - def destination(self): - """Returns the path within the library directory designated for this - item (i.e., where the file ought to be). - """ - libpath = self.library.directory - subpath_tmpl = Template(self.library.path_format) - - # build the mapping for substitution in the path template, beginning - # with the values from the database - mapping = {} - for key in metadata_keys: - value = getattr(self, key) - # sanitize the value for inclusion in a path: - # replace / and leading . with _ - if isinstance(value, basestring): - value.replace(os.sep, '_') - value = re.sub(r'[\\/:]|^\.', '_', value) - elif key in ('track', 'tracktotal', 'disc', 'disctotal'): - # pad with zeros - value = '%02i' % value - else: - value = str(value) - mapping[key] = value - - # Perform substitution. - subpath = subpath_tmpl.substitute(mapping) - - # Truncate path components. - comps = _components(subpath) - for i, comp in enumerate(comps): - if len(comp) > MAX_FILENAME_LENGTH: - comps[i] = comp[:MAX_FILENAME_LENGTH] - subpath = os.path.join(*comps) - - # Preserve extension. - _, extension = os.path.splitext(self.path) - subpath += extension - - return _normpath(os.path.join(libpath, subpath)) - def move(self, copy=False): """Move the item to its designated location within the library directory (provided by destination()). Subdirectories are created as @@ -308,7 +268,7 @@ class Item(object): Note that one should almost certainly call store() and library.save() after this method in order to keep on-disk data consistent. """ - dest = self.destination() + dest = self.library.destination(self) # Create necessary ancestry for the move. Like os.renames but only # halfway. @@ -689,7 +649,46 @@ class Library(BaseLibrary): self.conn.executescript(setup_sql) self.conn.commit() - + + def destination(self, item): + """Returns the path in the library directory designated for item + item (i.e., where the file ought to be). + """ + libpath = self.directory + subpath_tmpl = Template(self.path_format) + + # build the mapping for substitution in the path template, beginning + # with the values from the database + mapping = {} + for key in metadata_keys: + value = getattr(item, key) + # sanitize the value for inclusion in a path: + # replace / and leading . with _ + if isinstance(value, basestring): + value.replace(os.sep, '_') + value = re.sub(r'[\\/:]|^\.', '_', value) + elif key in ('track', 'tracktotal', 'disc', 'disctotal'): + # pad with zeros + value = '%02i' % value + else: + value = str(value) + mapping[key] = value + + # Perform substitution. + subpath = subpath_tmpl.substitute(mapping) + + # Truncate path components. + comps = _components(subpath) + for i, comp in enumerate(comps): + if len(comp) > MAX_FILENAME_LENGTH: + comps[i] = comp[:MAX_FILENAME_LENGTH] + subpath = os.path.join(*comps) + + # Preserve extension. + _, extension = os.path.splitext(item.path) + subpath += extension + + return _normpath(os.path.join(libpath, subpath)) #### main interface #### @@ -803,3 +802,5 @@ class Library(BaseLibrary): c = self.conn.execute(sql, subvals) return ResultIterator(c, self) + + diff --git a/test/test_db.py b/test/test_db.py index ebcdaf4c1..3e7c9998d 100755 --- a/test/test_db.py +++ b/test/test_db.py @@ -154,12 +154,12 @@ class DestinationTest(unittest.TestCase): def test_directory_works_with_trailing_slash(self): self.lib.directory = 'one/' self.lib.path_format = 'two' - self.assertEqual(self.i.destination(), np('one/two')) + self.assertEqual(self.lib.destination(self.i), np('one/two')) def test_directory_works_without_trailing_slash(self): self.lib.directory = 'one' self.lib.path_format = 'two' - self.assertEqual(self.i.destination(), np('one/two')) + self.assertEqual(self.lib.destination(self.i), np('one/two')) def test_destination_substitues_metadata_values(self): self.lib.directory = 'base' @@ -167,13 +167,15 @@ class DestinationTest(unittest.TestCase): self.i.title = 'three' self.i.artist = 'two' self.i.album = 'one' - self.assertEqual(self.i.destination(), np('base/one/two three')) + self.assertEqual(self.lib.destination(self.i), + np('base/one/two three')) def test_destination_preserves_extension(self): self.lib.directory = 'base' self.lib.path_format = '$title' self.i.path = 'hey.audioFormat' - self.assertEqual(self.i.destination(),np('base/the title.audioFormat')) + self.assertEqual(self.lib.destination(self.i), + np('base/the title.audioFormat')) def test_destination_pads_some_indices(self): self.lib.directory = 'base' @@ -185,11 +187,12 @@ class DestinationTest(unittest.TestCase): self.i.disctotal = 4 self.i.bpm = 5 self.i.year = 6 - self.assertEqual(self.i.destination(), np('base/01 02 03 04 5 6')) + self.assertEqual(self.lib.destination(self.i), + np('base/01 02 03 04 5 6')) def test_destination_escapes_slashes(self): self.i.album = 'one/two' - dest = self.i.destination() + dest = self.lib.destination(self.i) self.assertTrue('one' in dest) self.assertTrue('two' in dest) self.assertFalse('one/two' in dest) @@ -197,13 +200,13 @@ class DestinationTest(unittest.TestCase): def test_destination_long_names_truncated(self): self.i.title = 'X'*300 self.i.artist = 'Y'*300 - for c in self.i.destination().split(os.path.sep): + for c in self.lib.destination(self.i).split(os.path.sep): self.assertTrue(len(c) <= 255) def test_destination_long_names_keep_extension(self): self.i.title = 'X'*300 self.i.path = 'something.extn' - dest = self.i.destination() + dest = self.lib.destination(self.i) self.assertEqual(dest[-5:], '.extn') def suite(): From 4d1944f939bfc8c9cd0a1b3d8236e66e0d0e124d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 6 Apr 2010 10:17:53 -0700 Subject: [PATCH 06/11] better names: add vs. add_path --HG-- branch : device --- beets/library.py | 10 +++++----- test/test_db.py | 4 ++-- test/test_files.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/beets/library.py b/beets/library.py index ff1b46249..5f59e4938 100644 --- a/beets/library.py +++ b/beets/library.py @@ -218,7 +218,7 @@ class Item(object): self.library = library if not self.library: raise LibraryError('no library to add to') - self.library.add_item(self, copy) + self.library.add(self, copy) return self.id def remove(self): @@ -532,7 +532,7 @@ class BaseLibrary(object): ### basic operations ### - def add_item(self, item, copy=False): #FIXME rename to "add", copy default to true + def add(self, item, copy=False): #FIXME copy should default to true """Add the item as a new object to the library database. The id field will be updated; the new id is returned. If copy, then each item is copied to the destination location before it is added. @@ -616,14 +616,14 @@ class BaseLibrary(object): ### convenience methods ### - def add(self, path, copy=False): #FIXME change name to add_path() + def add_path(self, path, copy=False): items = [] for f in _walk_files(path): try: item = Item.from_path(_normpath(f), self) except FileTypeError: log.warn(f + ' of unknown type, skipping') - self.add_item(item, copy) + self.add(item, copy) class Library(BaseLibrary): @@ -692,7 +692,7 @@ class Library(BaseLibrary): #### main interface #### - def add_item(self, item, copy=False): + def add(self, item, copy=False): #FIXME make a deep copy of the item? item.library = self if copy: diff --git a/test/test_db.py b/test/test_db.py index 3e7c9998d..bb2d0c51e 100755 --- a/test/test_db.py +++ b/test/test_db.py @@ -106,8 +106,8 @@ class AddTest(unittest.TestCase): 'where composer="the composer"').fetchone()['grouping'] self.assertEqual(new_grouping, self.i.grouping) - def test_library_add_inserts_row(self): - self.lib.add(os.path.join('rsrc', 'full.mp3')) + def test_library_add_path_inserts_row(self): + self.lib.add_path(os.path.join('rsrc', 'full.mp3')) new_grouping = self.lib.conn.execute('select grouping from items ' 'where composer="the composer"').fetchone()['grouping'] self.assertEqual(new_grouping, self.i.grouping) diff --git a/test/test_files.py b/test/test_files.py index 92ace3d05..19df33b04 100755 --- a/test/test_files.py +++ b/test/test_files.py @@ -146,8 +146,8 @@ class AddTest(unittest.TestCase): if os.path.exists(self.dir): shutil.rmtree(self.dir) - def test_library_add_copies(self): - self.lib.add(os.path.join('rsrc', 'full.mp3'), copy=True) + def test_library_add_path_copies(self): + self.lib.add_path(os.path.join('rsrc', 'full.mp3'), copy=True) self.assertTrue(os.path.isfile(os.path.join(self.dir, 'item.mp3'))) class HelperTest(unittest.TestCase): From c7f98ccde12057fe4d29bff893f6e8314238415d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 6 Apr 2010 11:07:57 -0700 Subject: [PATCH 07/11] make the PodLibrary interface class more consistent; remove more back-references from Item to Library (including removing the delete() method, which was unused) --HG-- branch : device --- beets/device.py | 44 ++++++++++++++++++++++---------------------- beets/library.py | 39 ++++++++++++++------------------------- bts | 5 ++++- test/test_files.py | 24 ------------------------ 4 files changed, 40 insertions(+), 72 deletions(-) diff --git a/beets/device.py b/beets/device.py index 73d98fe3f..efcf62b9b 100644 --- a/beets/device.py +++ b/beets/device.py @@ -20,6 +20,7 @@ FIELD_MAP = { class PodLibrary(BaseLibrary): def __init__(self, path): self.db = gpod.Database(path) + self.syncing = False @classmethod def by_name(cls, name): @@ -28,41 +29,40 @@ class PodLibrary(BaseLibrary): def _start_sync(self): # Make sure we have a version of libgpod with these # iPhone-specific functions. + if self.syncing: + return if hasattr(gpod, 'itdb_start_sync'): gpod.itdb_start_sync(self.db._itdb) + self.syncing = True def _stop_sync(self): + if not self.syncing: + return if hasattr(gpod, 'itdb_stop_sync'): gpod.itdb_stop_sync(self.db._itdb) + self.syncing = False - def add_items(self, items): + def add(self, item): self._start_sync() - try: - for item in items: - track = self.db.new_Track() - track['userdata'] = { - 'transferred': 0, - 'hostname': socket.gethostname(), - 'charset': locale.getpreferredencoding(), - 'pc_mtime': os.stat(item.path).st_mtime, - } - track._set_userdata_utf8('filename', item.path.encode()) - for dname, bname in FIELD_MAP.items(): - track[dname] = getattr(item, bname) - track['tracklen'] = int(item.length * 1000) - self.db.copy_delayed_files() - finally: - self.db.close() - self._stop_sync() - - def add(self, path): - raise NotImplementedError + track = self.db.new_Track() + track['userdata'] = { + 'transferred': 0, + 'hostname': socket.gethostname(), + 'charset': locale.getpreferredencoding(), + 'pc_mtime': os.stat(item.path).st_mtime, + } + track._set_userdata_utf8('filename', item.path.encode()) + for dname, bname in FIELD_MAP.items(): + track[dname] = getattr(item, bname) + track['tracklen'] = int(item.length * 1000) + self.db.copy_delayed_files() def get(self, query=None): raise NotImplementedError def save(self): - raise NotImplementedError + self._stop_sync() + gpod.itdb_write(self.pod._itdb, None) # Browsing convenience. def artists(self, query=None): diff --git a/beets/library.py b/beets/library.py index 5f59e4938..15d8882af 100644 --- a/beets/library.py +++ b/beets/library.py @@ -141,7 +141,18 @@ class Item(object): self.dirty = {} self._fill_record(values) self._clear_dirty() - + + @classmethod + def from_path(cls, path, library=None): + """Creates a new item from the media file at the specified path. Sets + the item's library (but does not add the item) if library is + specified. + """ + i = cls({}) + i.read(path) + i.library = library + return i + def _fill_record(self, values): self.record = {} for key in item_keys: @@ -156,8 +167,7 @@ class Item(object): self.dirty[key] = False def __repr__(self): - return 'Item(' + repr(self.record) + \ - ', library=' + repr(self.library) + ')' + return 'Item(' + repr(self.record) + ')' #### item field accessors #### @@ -283,28 +293,7 @@ class Item(object): # Either copying or moving succeeded, so update the stored path. self.path = dest - - def delete(self): - """Deletes the item from the filesystem. If the item is located - in the library directory, any empty parent directories are trimmed. - Also calls remove(), deleting the appropriate row from the database. - - As with move(), library.save() should almost certainly be called after - invoking this (although store() should not). - """ - os.unlink(self.path) - self.remove() - - @classmethod - def from_path(cls, path, library=None): - """Creates a new item from the media file at the specified path. Sets - the item's library (but does not add the item) if library is - specified. - """ - i = cls({}) - i.read(path) - i.library = library - return i + diff --git a/bts b/bts index 8b94ca60f..2915c09c4 100755 --- a/bts +++ b/bts @@ -188,8 +188,11 @@ class BeetsApp(cmdln.Cmdln): from beets import device pod = device.PodLibrary.by_name(name) - pod.add_items(items) + for item in items: + pod.add(item) + pod.save() if __name__ == '__main__': app = BeetsApp() sys.exit(app.main()) + diff --git a/test/test_files.py b/test/test_files.py index 19df33b04..e40ad5166 100755 --- a/test/test_files.py +++ b/test/test_files.py @@ -71,30 +71,6 @@ class MoveTest(unittest.TestCase): self.i.move() self.assertEqual(self.i.path, beets.library._normpath(self.dest)) -class DeleteTest(unittest.TestCase): - def setUp(self): - # make a temporary file - self.path = join('rsrc', 'temp.mp3') - shutil.copy(join('rsrc', 'full.mp3'), self.path) - - # add it to a temporary library - self.lib = beets.library.Library(':memory:') - self.i = beets.library.Item.from_path(self.path) - self.i.add(self.lib) - def tearDown(self): - # make sure the temp file is gone - if os.path.exists(self.path): - os.remove(self.path) - - def test_delete_deletes_file(self): - self.i.delete() - self.assertTrue(not os.path.exists(self.path)) - - def test_delete_removes_from_db(self): - self.i.delete() - c = self.lib.conn.execute('select * from items where 1') - self.assertEqual(c.fetchone(), None) - class WalkTest(unittest.TestCase): def setUp(self): # create a directory structure for testing From 628cfbffe2677c9e7e1437a89bacc05f1c7bd5f0 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 6 Apr 2010 11:18:41 -0700 Subject: [PATCH 08/11] Item.move() now takes a library as an argument --HG-- branch : device --- beets/library.py | 6 +++--- bts | 2 +- test/test_files.py | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/beets/library.py b/beets/library.py index 15d8882af..1575f6172 100644 --- a/beets/library.py +++ b/beets/library.py @@ -264,7 +264,7 @@ class Item(object): #### dealing with files themselves #### - def move(self, copy=False): + def move(self, library, copy=False): """Move the item to its designated location within the library directory (provided by destination()). Subdirectories are created as needed. If the operation succeeds, the item's path field is updated to @@ -278,7 +278,7 @@ class Item(object): Note that one should almost certainly call store() and library.save() after this method in order to keep on-disk data consistent. """ - dest = self.library.destination(self) + dest = library.destination(self) # Create necessary ancestry for the move. Like os.renames but only # halfway. @@ -685,7 +685,7 @@ class Library(BaseLibrary): #FIXME make a deep copy of the item? item.library = self if copy: - item.move(copy=True) + item.move(self, copy=True) # build essential parts of query columns = ','.join([key for key in item_keys if key != 'id']) diff --git a/bts b/bts index 2915c09c4..d0fe3ed08 100755 --- a/bts +++ b/bts @@ -95,7 +95,7 @@ def tag_album(items, lib): # Change metadata and add to library. autotag.apply_metadata(items, info) for item in items: - item.move(True) + item.move(lib, True) item.add() item.write() diff --git a/test/test_files.py b/test/test_files.py index e40ad5166..9ed59eb97 100755 --- a/test/test_files.py +++ b/test/test_files.py @@ -52,23 +52,23 @@ class MoveTest(unittest.TestCase): shutil.rmtree(self.libdir) def test_move_arrives(self): - self.i.move() + self.i.move(self.lib) self.assertTrue(os.path.exists(self.dest)) def test_move_departs(self): - self.i.move() + self.i.move(self.lib) self.assertTrue(not os.path.exists(self.path)) def test_copy_arrives(self): - self.i.move(copy=True) + self.i.move(self.lib, copy=True) self.assertTrue(os.path.exists(self.dest)) def test_copy_does_not_depart(self): - self.i.move(copy=True) + self.i.move(self.lib, copy=True) self.assertTrue(os.path.exists(self.path)) def test_move_changes_path(self): - self.i.move() + self.i.move(self.lib) self.assertEqual(self.i.path, beets.library._normpath(self.dest)) class WalkTest(unittest.TestCase): From 68d43380b4226466e31065e6ba3f9b758c29e6c4 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 6 Apr 2010 11:36:00 -0700 Subject: [PATCH 09/11] remove remaining library-interaction methods from Item --HG-- branch : device --- beets/library.py | 41 +---------------------------------------- bts | 3 +-- test/test_db.py | 14 +++++++------- test/test_files.py | 2 +- 4 files changed, 10 insertions(+), 50 deletions(-) diff --git a/beets/library.py b/beets/library.py index 1575f6172..1daf92c9f 100644 --- a/beets/library.py +++ b/beets/library.py @@ -199,46 +199,6 @@ class Item(object): super(Item, self).__setattr__(key, value) - #### interaction with the database #### - - def load(self, load_id=None): - """Refresh the item's metadata from the library database. If fetch_id - is not specified, use the item's current id. - """ - if not self.library: - raise LibraryError('no library to store to') - self.library.load(self, load_id) - - def store(self, store_id=None, store_all=False): - """Save the item's metadata into the library database. If store_id is - specified, use it instead of the item's current id. If store_all is - true, save the entire record instead of just the dirty fields. - """ - if not self.library: - raise LibraryError('no library to store to') - self.library.store(self, store_id, store_all) - self._clear_dirty() - - def add(self, library=None, copy=False): - """Add the item as a new object to the library database. The id field - will be updated; the new id is returned. If library is specified, set - the item's library before adding. - """ - if library: - self.library = library - if not self.library: - raise LibraryError('no library to add to') - self.library.add(self, copy) - return self.id - - def remove(self): - """Removes the item from the database (leaving the file on disk). - """ - if not self.library: - raise LibraryError('no library to remove from') - self.library.remove(self) - - #### interaction with files' metadata #### def read(self, read_path=None): @@ -747,6 +707,7 @@ class Library(BaseLibrary): subvars.append(item.id) self.conn.execute(query, subvars) + item._clear_dirty() def remove(self, item): self.conn.execute('DELETE FROM items WHERE id=?', (item.id,)) diff --git a/bts b/bts index d0fe3ed08..554667ee5 100755 --- a/bts +++ b/bts @@ -96,11 +96,10 @@ def tag_album(items, lib): autotag.apply_metadata(items, info) for item in items: item.move(lib, True) - item.add() + lib.add(item) item.write() - class BeetsApp(cmdln.Cmdln): name = "bts" diff --git a/test/test_db.py b/test/test_db.py index bb2d0c51e..0c67f7a25 100755 --- a/test/test_db.py +++ b/test/test_db.py @@ -58,12 +58,12 @@ class LoadTest(unittest.TestCase): def test_load_restores_data_from_db(self): original_title = self.i.title self.i.title = 'something' - self.i.load() + self.lib.load(self.i) self.assertEqual(original_title, self.i.title) def test_load_clears_dirty_flags(self): self.i.artist = 'something' - self.i.load() + self.lib.load(self.i) self.assertTrue(not self.i.dirty['artist']) class StoreTest(unittest.TestCase): @@ -75,7 +75,7 @@ class StoreTest(unittest.TestCase): def test_store_changes_database_value(self): self.i.year = 1987 - self.i.store() + self.lib.store(self.i) new_year = self.lib.conn.execute('select year from items where ' 'title="Boracay"').fetchone()['year'] self.assertEqual(new_year, 1987) @@ -83,14 +83,14 @@ class StoreTest(unittest.TestCase): def test_store_only_writes_dirty_fields(self): original_genre = self.i.genre self.i.record['genre'] = 'beatboxing' # change value w/o dirtying - self.i.store() + self.lib.store(self.i) new_genre = self.lib.conn.execute('select genre from items where ' 'title="Boracay"').fetchone()['genre'] self.assertEqual(new_genre, original_genre) def test_store_clears_dirty_flags(self): self.i.composer = 'tvp' - self.i.store() + self.lib.store(self.i) self.assertTrue(not self.i.dirty['composer']) class AddTest(unittest.TestCase): @@ -101,7 +101,7 @@ class AddTest(unittest.TestCase): self.lib.conn.close() def test_item_add_inserts_row(self): - self.i.add() + self.lib.add(self.i) new_grouping = self.lib.conn.execute('select grouping from items ' 'where composer="the composer"').fetchone()['grouping'] self.assertEqual(new_grouping, self.i.grouping) @@ -121,7 +121,7 @@ class RemoveTest(unittest.TestCase): self.lib.conn.close() def test_remove_deletes_from_db(self): - self.i.remove() + self.lib.remove(self.i) c = self.lib.conn.execute('select * from items where id=3') self.assertEqual(c.fetchone(), None) diff --git a/test/test_files.py b/test/test_files.py index 9ed59eb97..d3a94d8f9 100755 --- a/test/test_files.py +++ b/test/test_files.py @@ -34,7 +34,7 @@ class MoveTest(unittest.TestCase): # add it to a temporary library self.lib = beets.library.Library(':memory:') self.i = beets.library.Item.from_path(self.path) - self.i.add(self.lib) + self.lib.add(self.i) # set up the destination self.libdir = join('rsrc', 'testlibdir') From 38801813bea7b8e1b24692e0f7bd942b47807440 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 6 Apr 2010 11:45:31 -0700 Subject: [PATCH 10/11] Item no longer retains a Library (changed constructors) --HG-- branch : device --- beets/autotag/__init__.py | 4 ++-- beets/library.py | 14 +++++--------- bts | 3 +-- test/test_db.py | 4 ++-- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 2e99c9c92..b1e5fbb63 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -65,7 +65,7 @@ def _first_n(it, n): break yield v -def albums_in_dir(path, lib=None): +def albums_in_dir(path): """Recursively searches the given directory and returns an iterable of lists of items where each list is probably an album. Specifically, any folder containing any media files is an album. @@ -75,7 +75,7 @@ def albums_in_dir(path, lib=None): items = [] for filename in files: try: - i = library.Item.from_path(os.path.join(root, filename), lib) + i = library.Item.from_path(os.path.join(root, filename)) except mediafile.FileTypeError: pass else: diff --git a/beets/library.py b/beets/library.py index 1daf92c9f..14572984f 100644 --- a/beets/library.py +++ b/beets/library.py @@ -136,21 +136,17 @@ def _components(path): class Item(object): - def __init__(self, values, library=None): - self.library = library + def __init__(self, values): self.dirty = {} self._fill_record(values) self._clear_dirty() @classmethod - def from_path(cls, path, library=None): - """Creates a new item from the media file at the specified path. Sets - the item's library (but does not add the item) if library is - specified. + def from_path(cls, path): + """Creates a new item from the media file at the specified path. """ i = cls({}) i.read(path) - i.library = library return i def _fill_record(self, values): @@ -450,7 +446,7 @@ class ResultIterator(object): except StopIteration: self.cursor.close() raise - return Item(row, self.library) + return Item(row) @@ -569,7 +565,7 @@ class BaseLibrary(object): items = [] for f in _walk_files(path): try: - item = Item.from_path(_normpath(f), self) + item = Item.from_path(_normpath(f)) except FileTypeError: log.warn(f + ' of unknown type, skipping') self.add(item, copy) diff --git a/bts b/bts index 554667ee5..43cf1faf3 100755 --- a/bts +++ b/bts @@ -135,8 +135,7 @@ class BeetsApp(cmdln.Cmdln): ${cmd_option_list} """ for path in paths: - for album in autotag.albums_in_dir(os.path.expanduser(path), - self.lib): + for album in autotag.albums_in_dir(os.path.expanduser(path)): print tag_album(album, self.lib) self.lib.save() diff --git a/test/test_db.py b/test/test_db.py index 0c67f7a25..8739388cd 100755 --- a/test/test_db.py +++ b/test/test_db.py @@ -23,7 +23,7 @@ import beets.library def lib(): return beets.library.Library('rsrc' + os.sep + 'test.blb') def boracay(l): return beets.library.Item(l.conn.execute('select * from items ' - 'where id=3').fetchone(), l) + 'where id=3').fetchone()) def item(lib=None): return beets.library.Item({ 'title': u'the title', 'artist': u'the artist', @@ -45,7 +45,7 @@ def item(lib=None): return beets.library.Item({ 'path': 'somepath', 'length': 60.0, 'bitrate': 128000, -}, lib) +}) np = beets.library._normpath class LoadTest(unittest.TestCase): From e1c1b1e0387e7a5909f07675fb738a8e6f352073 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 6 Apr 2010 12:07:21 -0700 Subject: [PATCH 11/11] fill out PodLibrary's implementation of the Library methods --HG-- branch : device --- beets/device.py | 35 +++++++++++++++++++++++++++++------ beets/library.py | 5 +++-- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/beets/device.py b/beets/device.py index efcf62b9b..59824bd39 100644 --- a/beets/device.py +++ b/beets/device.py @@ -1,8 +1,24 @@ -import gpod +# This file is part of beets. +# Copyright 2009, Adrian Sampson. +# +# Beets is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Beets is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with beets. If not, see . + import os import sys import socket import locale +import gpod from beets.library import BaseLibrary, Item FIELD_MAP = { @@ -21,7 +37,15 @@ class PodLibrary(BaseLibrary): def __init__(self, path): self.db = gpod.Database(path) self.syncing = False + # Browsing convenience. + def artists(self, query=None): + raise NotImplementedError + def albums(self, artist=None, query=None): + raise NotImplementedError + + def items(self, artist=None, album=None, title=None, query=None): + raise NotImplementedError @classmethod def by_name(cls, name): return cls(os.path.join(os.path.expanduser('~'), '.gvfs', name)) @@ -62,15 +86,14 @@ class PodLibrary(BaseLibrary): def save(self): self._stop_sync() - gpod.itdb_write(self.pod._itdb, None) + gpod.itdb_write(self.db._itdb, None) - # Browsing convenience. - def artists(self, query=None): + def load(self, item, load_id=None): raise NotImplementedError - def albums(self, artist=None, query=None): + def store(self, item, store_id=None, store_all=False): raise NotImplementedError - def items(self, artist=None, album=None, title=None, query=None): + def remove(self, item): raise NotImplementedError diff --git a/beets/library.py b/beets/library.py index 14572984f..87f8f9a8d 100644 --- a/beets/library.py +++ b/beets/library.py @@ -452,8 +452,9 @@ class ResultIterator(object): class BaseLibrary(object): - """Base class for music libraries.""" - + """Abstract base class for music libraries, which are loosely + defined as sets of Items. + """ def __init__(self): raise NotImplementedError