mirror of
https://github.com/beetbox/beets.git
synced 2025-12-31 13:02:47 +01:00
consolidate update command, removing album-munging logic
This commit is contained in:
parent
fdf697dc90
commit
e84c3e7abd
4 changed files with 164 additions and 140 deletions
5
NEWS
5
NEWS
|
|
@ -3,6 +3,11 @@
|
|||
* A new "beet modify" command enables manual, command-line-based
|
||||
modification of music metadata. Pass it a query along with
|
||||
field=value that specify the changes you want to make.
|
||||
* A new "beet update" command updates the database to reflect
|
||||
changes in the on-disk metadata. You can now use an external
|
||||
program to edit tags on files, remove files and directories, etc.,
|
||||
and then run "beet update" to make sure your beets library is in
|
||||
sync. This will also rename files to reflect their new metadata.
|
||||
* Handle exceptions thrown when running Mutagen.
|
||||
* Fix a missing __future__ import in embedart on Python 2.5.
|
||||
* Fix ID3 and MPEG-4 tag names for the album-artist field.
|
||||
|
|
|
|||
|
|
@ -19,16 +19,13 @@ import shutil
|
|||
import sys
|
||||
from string import Template
|
||||
import logging
|
||||
from beets.mediafile import MediaFile, UnreadableFileError
|
||||
from beets.mediafile import MediaFile
|
||||
from beets import plugins
|
||||
from beets import util
|
||||
from beets.util import bytestring_path, syspath, normpath
|
||||
|
||||
MAX_FILENAME_LENGTH = 200
|
||||
|
||||
ITEM_MODIFIED = 1
|
||||
ITEM_DELETED = 2
|
||||
|
||||
# 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.
|
||||
|
|
@ -963,58 +960,7 @@ class Library(BaseLibrary):
|
|||
if delete:
|
||||
util.soft_remove(item.path)
|
||||
util.prune_dirs(os.path.dirname(item.path), self.directory)
|
||||
|
||||
def update(self, item, move=True):
|
||||
"""Reads the item's metadata from file, and updates the library. If
|
||||
move, then the files will be moved to reflect the changes.
|
||||
"""
|
||||
modified = False
|
||||
deleted = False
|
||||
|
||||
old_album = self.get_album(item)
|
||||
|
||||
try:
|
||||
item.read()
|
||||
except UnreadableFileError:
|
||||
# File no longer exists
|
||||
deleted = True
|
||||
self.remove(item, False)
|
||||
else:
|
||||
# Has the metadata been modified?
|
||||
for key in ITEM_KEYS:
|
||||
if (key != 'id') and item.dirty[key]:
|
||||
modified = True
|
||||
break
|
||||
|
||||
if modified:
|
||||
new_album = self.get_album_by_item_properties(item)
|
||||
if new_album is None:
|
||||
# No existing album matching the new metadata, so we create one
|
||||
new_album = self.add_album((item,))
|
||||
if move and old_album and old_album.artpath:
|
||||
new_album.set_art(old_album.artpath)
|
||||
item.album_id = new_album.id
|
||||
|
||||
old_path = item.path
|
||||
if move:
|
||||
item.move(self)
|
||||
|
||||
self.store(item)
|
||||
|
||||
# Delete old album if it's empty
|
||||
if move and old_album:
|
||||
item_iter = old_album.items()
|
||||
try:
|
||||
item_iter.next()
|
||||
except StopIteration:
|
||||
# Album is empty.
|
||||
old_album.remove(True, False)
|
||||
util.prune_dirs(os.path.dirname(old_path), self.directory)
|
||||
|
||||
if deleted:
|
||||
return ITEM_DELETED
|
||||
if modified:
|
||||
return ITEM_MODIFIED
|
||||
|
||||
|
||||
# Querying.
|
||||
|
||||
|
|
@ -1123,30 +1069,6 @@ class Library(BaseLibrary):
|
|||
self.store(item)
|
||||
|
||||
return album
|
||||
|
||||
def get_album_by_item_properties(self, item):
|
||||
"""Given an item, return an Album object that matches all the item's
|
||||
fields listed in ALBUM_KEYS_ITEM. If no such album exists, returns
|
||||
None."""
|
||||
item_values = dict(
|
||||
(key, getattr(item, key)) for key in ALBUM_KEYS_ITEM)
|
||||
|
||||
queries = []
|
||||
for key in ALBUM_KEYS_ITEM:
|
||||
queries.append(MatchQuery(key, item_values[key]))
|
||||
super_query = AndQuery(queries)
|
||||
where, subvals = super_query.clause()
|
||||
|
||||
sql = "SELECT * FROM albums " + \
|
||||
"WHERE " + where + \
|
||||
" ORDER BY albumartist, album"
|
||||
c = self.conn.execute(sql, subvals)
|
||||
try:
|
||||
record = c.fetchone()
|
||||
finally:
|
||||
c.close()
|
||||
if record:
|
||||
return Album(self, dict(record))
|
||||
|
||||
class Album(BaseAlbum):
|
||||
"""Provides access to information about albums stored in a
|
||||
|
|
@ -1231,10 +1153,6 @@ class Album(BaseAlbum):
|
|||
'DELETE FROM albums WHERE id=?',
|
||||
(self.id,)
|
||||
)
|
||||
|
||||
def update(self):
|
||||
for item in self.items():
|
||||
self._library.update(item)
|
||||
|
||||
def move(self, copy=False):
|
||||
"""Moves (or copies) all items to their destination. Any
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import logging
|
|||
import sys
|
||||
import os
|
||||
import time
|
||||
import copy
|
||||
import itertools
|
||||
|
||||
from beets import ui
|
||||
|
|
@ -29,7 +28,6 @@ from beets import autotag
|
|||
import beets.autotag.art
|
||||
from beets import plugins
|
||||
from beets import importer
|
||||
from beets.library import ITEM_MODIFIED, ITEM_DELETED
|
||||
from beets.util import syspath, normpath, ancestry
|
||||
from beets import library
|
||||
|
||||
|
|
@ -41,6 +39,7 @@ log = logging.getLogger('beets')
|
|||
default_commands = []
|
||||
|
||||
# Utility.
|
||||
|
||||
def _do_query(lib, query, album, also_items=True):
|
||||
"""For commands that operate on matched items, performs a query
|
||||
and returns a list of matching items and a list of matching
|
||||
|
|
@ -66,6 +65,13 @@ def _do_query(lib, query, album, also_items=True):
|
|||
|
||||
return items, albums
|
||||
|
||||
def _showdiff(field, oldval, newval, color):
|
||||
"""Prints out a human-readable field difference line."""
|
||||
if newval != oldval:
|
||||
if color:
|
||||
oldval, newval = ui.colordiff(oldval, newval)
|
||||
print_(u' %s: %s -> %s' % (field, oldval, newval))
|
||||
|
||||
|
||||
# import: Autotagger and importer.
|
||||
|
||||
|
|
@ -184,21 +190,6 @@ def show_item_change(item, info, dist, color):
|
|||
|
||||
print_('(Similarity: %s)' % dist_string(dist, color))
|
||||
|
||||
def show_item_update(old_item, new_item, color=True):
|
||||
"""Print out the changes detected on an existing item."""
|
||||
old_artist, new_artist = old_item.artist, new_item.artist
|
||||
old_title, new_title = old_item.title, new_item.title
|
||||
|
||||
if old_artist != new_artist or old_title != new_title:
|
||||
if color:
|
||||
old_artist, new_artist = ui.colordiff(old_artist, new_artist)
|
||||
old_title, new_title = ui.colordiff(old_title, new_title)
|
||||
|
||||
print_("Updated: %s - %s -> %s - %s" % (old_artist, old_title, new_artist, new_title))
|
||||
|
||||
else:
|
||||
print_("Updated: %s - %s (secondary tags)" % (old_artist, old_title))
|
||||
|
||||
def should_resume(config, path):
|
||||
return ui.input_yn("Import of the directory:\n%s"
|
||||
"\nwas interrupted. Resume (Y/n)?" % path)
|
||||
|
|
@ -672,52 +663,76 @@ list_cmd.func = list_func
|
|||
default_commands.append(list_cmd)
|
||||
|
||||
|
||||
# update: Query and update library contents.
|
||||
# update: Update library contents according to on-disk tags.
|
||||
|
||||
def update_items(lib, query, album, path):
|
||||
"""Print out items in lib matching query. If album, then search for
|
||||
albums instead of single items. If path, print the matched objects'
|
||||
paths instead of human-readable information about them.
|
||||
def update_items(lib, query, album, move, color):
|
||||
"""For all the items matched by the query, update the library to
|
||||
reflect the item's embedded tags.
|
||||
"""
|
||||
# Get the matching items.
|
||||
if album:
|
||||
albums = list(lib.albums(query))
|
||||
items = []
|
||||
for al in albums:
|
||||
items += al.items()
|
||||
else:
|
||||
items = list(lib.items(query))
|
||||
items, _ = _do_query(lib, query, album)
|
||||
|
||||
if not items:
|
||||
print_('No matching items found.')
|
||||
return
|
||||
# Walk through the items and pick up their changes.
|
||||
affected_albums = set()
|
||||
for item in items:
|
||||
# Item deleted?
|
||||
if not os.path.exists(syspath(item.path)):
|
||||
print_(u'X %s - %s' % (item.artist, item.title))
|
||||
lib.remove(item, True)
|
||||
affected_albums.add(item.album_id)
|
||||
continue
|
||||
|
||||
# Show all the items.
|
||||
#for item in items:
|
||||
# print_(item.artist + ' - ' + item.album + ' - ' + item.title)
|
||||
# Read new data.
|
||||
old_data = dict(item.record)
|
||||
item.read()
|
||||
|
||||
# Remove (and possibly delete) items.
|
||||
if album:
|
||||
for al in albums:
|
||||
al.update()
|
||||
else:
|
||||
for item in items:
|
||||
old_item = copy.deepcopy(item)
|
||||
ret = lib.update(item)
|
||||
if ret == ITEM_MODIFIED:
|
||||
show_item_update(old_item, item)
|
||||
elif ret == ITEM_DELETED:
|
||||
print_("Deleted: %s - %s" % (item.artist, item.title))
|
||||
# Get and save metadata changes.
|
||||
changes = {}
|
||||
for key in library.ITEM_KEYS_META:
|
||||
if item.dirty[key]:
|
||||
changes[key] = old_data[key], getattr(item, key)
|
||||
if changes:
|
||||
# Something changed.
|
||||
print_(u'* %s - %s' % (item.artist, item.title))
|
||||
for key, (oldval, newval) in changes.iteritems():
|
||||
_showdiff(key, oldval, newval, color)
|
||||
|
||||
# Move the item if it's in the library.
|
||||
if move and lib.directory in ancestry(item.path):
|
||||
item.move(lib)
|
||||
|
||||
lib.store(item)
|
||||
affected_albums.add(item.album_id)
|
||||
|
||||
# Modify affected albums to reflect changes in their items.
|
||||
for album_id in affected_albums:
|
||||
if album_id is None: # Singletons.
|
||||
continue
|
||||
album = lib.get_album(album_id)
|
||||
if not album: # Empty albums have already been removed.
|
||||
log.debug('emptied album %i' % album_id)
|
||||
continue
|
||||
al_items = list(album.items())
|
||||
|
||||
# Update album structure to reflect an item in it.
|
||||
for key in library.ALBUM_KEYS_ITEM:
|
||||
setattr(album, key, getattr(al_items[0], key))
|
||||
|
||||
# Move album art (and any inconsistent items).
|
||||
if move and lib.directory in ancestry(al_items[0].path):
|
||||
log.debug('moving album %i' % album_id)
|
||||
album.move()
|
||||
|
||||
lib.save()
|
||||
|
||||
update_cmd = ui.Subcommand('update', help='update the library', aliases=('upd','up',))
|
||||
update_cmd = ui.Subcommand('update',
|
||||
help='update the library', aliases=('upd','up',))
|
||||
update_cmd.parser.add_option('-a', '--album', action='store_true',
|
||||
help='show matching albums instead of tracks')
|
||||
update_cmd.parser.add_option('-p', '--path', action='store_true',
|
||||
help='print paths for matched items or albums')
|
||||
update_cmd.parser.add_option('-M', '--nomove', action='store_false',
|
||||
default=True, dest='move', help="don't move files in library")
|
||||
def update_func(lib, config, opts, args):
|
||||
update_items(lib, decargs(args), opts.album, opts.path)
|
||||
color = ui.config_val(config, 'beets', 'color', DEFAULT_COLOR, bool)
|
||||
update_items(lib, decargs(args), opts.album, opts.move, color)
|
||||
update_cmd.func = update_func
|
||||
default_commands.append(update_cmd)
|
||||
|
||||
|
|
@ -856,10 +871,7 @@ def modify_items(lib, mods, query, write, move, album, color, confirm):
|
|||
# Show each change.
|
||||
for field, value in fsets.iteritems():
|
||||
curval = getattr(obj, field)
|
||||
if curval != value:
|
||||
if color:
|
||||
curval, value = ui.colordiff(curval, value)
|
||||
print_(u' %s: %s -> %s' % (field, curval, value))
|
||||
_showdiff(field, curval, value, color)
|
||||
|
||||
# Confirm.
|
||||
if confirm:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from beets import ui
|
|||
from beets.ui import commands
|
||||
from beets import autotag
|
||||
from beets import importer
|
||||
from beets.mediafile import MediaFile
|
||||
|
||||
class ListTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
|
@ -198,6 +199,94 @@ class ModifyTest(unittest.TestCase):
|
|||
item.read()
|
||||
self.assertFalse('newAlbum' in item.path)
|
||||
|
||||
class UpdateTest(unittest.TestCase, _common.ExtraAsserts):
|
||||
def setUp(self):
|
||||
self.io = _common.DummyIO()
|
||||
self.io.install()
|
||||
|
||||
self.libdir = os.path.join(_common.RSRC, 'testlibdir')
|
||||
os.mkdir(self.libdir)
|
||||
|
||||
# Copy a file into the library.
|
||||
self.lib = library.Library(':memory:', self.libdir)
|
||||
self.i = library.Item.from_path(os.path.join(_common.RSRC, 'full.mp3'))
|
||||
self.lib.add(self.i, True)
|
||||
self.album = self.lib.add_album([self.i])
|
||||
|
||||
# Album art.
|
||||
artfile = os.path.join(_common.RSRC, 'testart.jpg')
|
||||
_common.touch(artfile)
|
||||
self.album.set_art(artfile)
|
||||
os.remove(artfile)
|
||||
|
||||
def tearDown(self):
|
||||
self.io.restore()
|
||||
shutil.rmtree(self.libdir)
|
||||
|
||||
def _update(self, query=(), album=False, move=False):
|
||||
self.io.addinput('y')
|
||||
commands.update_items(self.lib, query, album, move, True)
|
||||
|
||||
def test_delete_removes_item(self):
|
||||
self.assertTrue(list(self.lib.items()))
|
||||
os.remove(self.i.path)
|
||||
self._update()
|
||||
self.assertFalse(list(self.lib.items()))
|
||||
|
||||
def test_delete_removes_album(self):
|
||||
self.assertTrue(self.lib.albums())
|
||||
os.remove(self.i.path)
|
||||
self._update()
|
||||
self.assertFalse(self.lib.albums())
|
||||
|
||||
def test_delete_removes_album_art(self):
|
||||
artpath = self.album.artpath
|
||||
self.assertExists(artpath)
|
||||
os.remove(self.i.path)
|
||||
self._update()
|
||||
self.assertNotExists(artpath)
|
||||
|
||||
def test_modified_metadata_detected(self):
|
||||
mf = MediaFile(self.i.path)
|
||||
mf.title = 'differentTitle'
|
||||
mf.save()
|
||||
self._update()
|
||||
item = self.lib.items().next()
|
||||
self.assertEqual(item.title, 'differentTitle')
|
||||
|
||||
def test_modified_metadata_moved(self):
|
||||
mf = MediaFile(self.i.path)
|
||||
mf.title = 'differentTitle'
|
||||
mf.save()
|
||||
self._update(move=True)
|
||||
item = self.lib.items().next()
|
||||
self.assertTrue('differentTitle' in item.path)
|
||||
|
||||
def test_modified_metadata_not_moved(self):
|
||||
mf = MediaFile(self.i.path)
|
||||
mf.title = 'differentTitle'
|
||||
mf.save()
|
||||
self._update(move=False)
|
||||
item = self.lib.items().next()
|
||||
self.assertTrue('differentTitle' not in item.path)
|
||||
|
||||
def test_modified_album_metadata_moved(self):
|
||||
mf = MediaFile(self.i.path)
|
||||
mf.album = 'differentAlbum'
|
||||
mf.save()
|
||||
self._update(move=True)
|
||||
item = self.lib.items().next()
|
||||
self.assertTrue('differentAlbum' in item.path)
|
||||
|
||||
def test_modified_album_metadata_art_moved(self):
|
||||
artpath = self.album.artpath
|
||||
mf = MediaFile(self.i.path)
|
||||
mf.album = 'differentAlbum'
|
||||
mf.save()
|
||||
self._update(move=True)
|
||||
album = self.lib.albums()[0]
|
||||
self.assertNotEqual(artpath, album.artpath)
|
||||
|
||||
class PrintTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.io = _common.DummyIO()
|
||||
|
|
|
|||
Loading…
Reference in a new issue