Merge pull request #2231 from dangmai/selective-update

Selective field updates
This commit is contained in:
Adrian Sampson 2016-10-23 16:36:33 -04:00
commit dab54043a1
5 changed files with 113 additions and 32 deletions

8
beets/dbcore/db.py Normal file → Executable file
View file

@ -342,15 +342,19 @@ class Model(object):
# Database interaction (CRUD methods).
def store(self):
def store(self, fields=None):
"""Save the object's metadata into the library database.
:param fields: the fields to be stored. If not specified, all fields
will be.
"""
if fields is None:
fields = self._fields
self._check_db()
# Build assignments for query.
assignments = []
subvars = []
for key in self._fields:
for key in fields:
if key != 'id' and key in self._dirty:
self._dirty.remove(key)
assignments.append(key + '=?')

45
beets/library.py Normal file → Executable file
View file

@ -323,8 +323,8 @@ class LibModel(dbcore.Model):
funcs.update(plugins.template_funcs())
return funcs
def store(self):
super(LibModel, self).store()
def store(self, fields=None):
super(LibModel, self).store(fields)
plugins.send('database_change', lib=self._db, model=self)
def remove(self):
@ -729,7 +729,8 @@ class Item(LibModel):
self._db._memotable = {}
def move(self, copy=False, link=False, basedir=None, with_album=True):
def move(self, copy=False, link=False, basedir=None, with_album=True,
store=True):
"""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
@ -745,10 +746,11 @@ class Item(LibModel):
move its art. (This can be disabled by passing
with_album=False.)
The item is stored to the database if it is in the database, so
any dirty fields prior to the move() call will be written as a
side effect. You probably want to call save() to commit the DB
transaction.
By default, the item is stored to the database if it is in the
database, so any dirty fields prior to the move() call will be written
as a side effect. You probably want to call save() to commit the DB
transaction. If `store` is true however, the item won't be stored, and
you'll have to manually store it after invoking this method.
"""
self._check_db()
dest = self.destination(basedir=basedir)
@ -759,14 +761,16 @@ class Item(LibModel):
# Perform the move and store the change.
old_path = self.path
self.move_file(dest, copy, link)
self.store()
if store:
self.store()
# If this item is in an album, move its art.
if with_album:
album = self.get_album()
if album:
album.move_art(copy)
album.store()
if store:
album.store()
# Prune vacated directory.
if not copy:
@ -1000,26 +1004,31 @@ class Album(LibModel):
util.prune_dirs(os.path.dirname(old_art),
self._db.directory)
def move(self, copy=False, link=False, basedir=None):
def move(self, copy=False, link=False, basedir=None, store=True):
"""Moves (or copies) all items to their destination. Any album
art moves along with them. basedir overrides the library base
directory for the destination. The album is stored to the
database, persisting any modifications to its metadata.
directory for the destination. By default, the album is stored to the
database, persisting any modifications to its metadata. If `store` is
true however, the album is not stored automatically, and you'll have
to manually store it after invoking this method.
"""
basedir = basedir or self._db.directory
# Ensure new metadata is available to items for destination
# computation.
self.store()
if store:
self.store()
# Move items.
items = list(self.items())
for item in items:
item.move(copy, link, basedir=basedir, with_album=False)
item.move(copy, link, basedir=basedir, with_album=False,
store=store)
# Move art.
self.move_art(copy, link)
self.store()
if store:
self.store()
def item_dir(self):
"""Returns the directory containing the album's first item,
@ -1108,9 +1117,11 @@ class Album(LibModel):
plugins.send('art_set', album=self)
def store(self):
def store(self, fields=None):
"""Update the database with the album information. The album's
tracks are also updated.
:param fields: The fields to be stored. If not specified, all fields
will be.
"""
# Get modified track fields.
track_updates = {}
@ -1119,7 +1130,7 @@ class Album(LibModel):
track_updates[key] = self[key]
with self._db.transaction():
super(Album, self).store()
super(Album, self).store(fields)
if track_updates:
for item in self.items():
for key, value in track_updates.items():

37
beets/ui/commands.py Normal file → Executable file
View file

@ -1081,11 +1081,18 @@ default_commands.append(list_cmd)
# update: Update library contents according to on-disk tags.
def update_items(lib, query, album, move, pretend):
def update_items(lib, query, album, move, pretend, fields):
"""For all the items matched by the query, update the library to
reflect the item's embedded tags.
:param fields: The fields to be stored. If not specified, all fields will
be.
"""
with lib.transaction():
if move and fields is not None and 'path' not in fields:
# Special case: if an item needs to be moved, the path field has to
# updated; otherwise the new path will not be reflected in the
# database.
fields.append('path')
items, _ = _do_query(lib, query, album)
# Walk through the items and pick up their changes.
@ -1124,24 +1131,25 @@ def update_items(lib, query, album, move, pretend):
item._dirty.discard(u'albumartist')
# Check for and display changes.
changed = ui.show_model_changes(item,
fields=library.Item._media_fields)
changed = ui.show_model_changes(
item,
fields=fields or library.Item._media_fields)
# Save changes.
if not pretend:
if changed:
# Move the item if it's in the library.
if move and lib.directory in ancestry(item.path):
item.move()
item.move(store=False)
item.store()
item.store(fields=fields)
affected_albums.add(item.album_id)
else:
# The file's mtime was different, but there were no
# changes to the metadata. Store the new mtime,
# which is set in the call to read(), so we don't
# check this again in the future.
item.store()
item.store(fields=fields)
# Skip album changes while pretending.
if pretend:
@ -1160,17 +1168,24 @@ def update_items(lib, query, album, move, pretend):
# Update album structure to reflect an item in it.
for key in library.Album.item_keys:
album[key] = first_item[key]
album.store()
album.store(fields=fields)
# Move album art (and any inconsistent items).
if move and lib.directory in ancestry(first_item.path):
log.debug(u'moving album {0}', album_id)
album.move()
# Manually moving and storing the album.
items = list(album.items())
for item in items:
item.move(store=False)
item.store(fields=fields)
album.move(store=False)
album.store(fields=fields)
def update_func(lib, opts, args):
update_items(lib, decargs(args), opts.album, ui.should_move(opts.move),
opts.pretend)
opts.pretend, opts.fields)
update_cmd = ui.Subcommand(
@ -1190,6 +1205,10 @@ update_cmd.parser.add_option(
u'-p', u'--pretend', action='store_true',
help=u"show all changes but do nothing"
)
update_cmd.parser.add_option(
u'-F', u'--field', default=None, action='append', dest='fields',
help=u'list of fields to update'
)
update_cmd.func = update_func
default_commands.append(update_cmd)

View file

@ -272,7 +272,7 @@ update
``````
::
beet update [-aM] QUERY
beet update [-F] FIELD [-aM] QUERY
Update the library (and, optionally, move files) to reflect out-of-band metadata
changes and file deletions.
@ -288,6 +288,11 @@ To perform a "dry run" of an update, just use the ``-p`` (for "pretend") flag.
This will show you all the proposed changes but won't actually change anything
on disk.
By default, all the changed metadata will be populated back to the database.
If you only want certain fields to be written, specify them with the ```-F```
flags (which can be used multiple times). For the list of supported fields,
please see ```beet fields```.
When an updated track is part of an album, the album-level fields of *all*
tracks from the album are also updated. (Specifically, the command copies
album-level data from the first track on the album and applies it to the
@ -318,7 +323,7 @@ You can think of this command as the opposite of :ref:`update-cmd`.
The ``-p`` option previews metadata changes without actually applying them.
The ``-f`` option forces a write to the file, even if the file tags match the database. This is useful for making sure that enabled plugins that run on write (e.g., the Scrub and Zero plugins) are run on the file.
The ``-f`` option forces a write to the file, even if the file tags match the database. This is useful for making sure that enabled plugins that run on write (e.g., the Scrub and Zero plugins) are run on the file.

View file

@ -499,12 +499,14 @@ class UpdateTest(_common.TestCase):
self.album.store()
os.remove(artfile)
def _update(self, query=(), album=False, move=False, reset_mtime=True):
def _update(self, query=(), album=False, move=False, reset_mtime=True,
fields=None):
self.io.addinput('y')
if reset_mtime:
self.i.mtime = 0
self.i.store()
commands.update_items(self.lib, query, album, move, False)
commands.update_items(self.lib, query, album, move, False,
fields=fields)
def test_delete_removes_item(self):
self.assertTrue(list(self.lib.items()))
@ -549,6 +551,26 @@ class UpdateTest(_common.TestCase):
item = self.lib.items().get()
self.assertTrue(b'differentTitle' not in item.path)
def test_selective_modified_metadata_moved(self):
mf = MediaFile(self.i.path)
mf.title = u'differentTitle'
mf.genre = u'differentGenre'
mf.save()
self._update(move=True, fields=['title'])
item = self.lib.items().get()
self.assertTrue(b'differentTitle' in item.path)
self.assertNotEqual(item.genre, u'differentGenre')
def test_selective_modified_metadata_not_moved(self):
mf = MediaFile(self.i.path)
mf.title = u'differentTitle'
mf.genre = u'differentGenre'
mf.save()
self._update(move=False, fields=['title'])
item = self.lib.items().get()
self.assertTrue(b'differentTitle' not in item.path)
self.assertNotEqual(item.genre, u'differentGenre')
def test_modified_album_metadata_moved(self):
mf = MediaFile(self.i.path)
mf.album = u'differentAlbum'
@ -566,6 +588,26 @@ class UpdateTest(_common.TestCase):
album = self.lib.albums()[0]
self.assertNotEqual(artpath, album.artpath)
def test_selective_modified_album_metadata_moved(self):
mf = MediaFile(self.i.path)
mf.album = u'differentAlbum'
mf.genre = u'differentGenre'
mf.save()
self._update(move=True, fields=['album'])
item = self.lib.items().get()
self.assertTrue(b'differentAlbum' in item.path)
self.assertNotEqual(item.genre, u'differentGenre')
def test_selective_modified_album_metadata_not_moved(self):
mf = MediaFile(self.i.path)
mf.album = u'differentAlbum'
mf.genre = u'differentGenre'
mf.save()
self._update(move=True, fields=['genre'])
item = self.lib.items().get()
self.assertTrue(b'differentAlbum' not in item.path)
self.assertEqual(item.genre, u'differentGenre')
def test_mtime_match_skips_update(self):
mf = MediaFile(self.i.path)
mf.title = u'differentTitle'