beets/beets/ui/commands.py
Adrian Sampson cc3ec0d8aa albums() browse function now returns Album objects
As part of this, the BaseLibrary class was also adapted to include a notion of
albums. This is reflected by the new BaseAlbum class, which the Album class
(formerly _AlbumInfo) completely replaces in the concrete Library. The BaseAlbum
class just fetches metadata from the underlying items.
2010-07-21 15:02:08 -07:00

433 lines
15 KiB
Python

# This file is part of beets.
# Copyright 2010, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""This module provides the default commands for beets' command-line
interface.
"""
import os
import logging
from beets import ui
from beets.ui import print_
from beets import autotag
from beets import library
from beets.mediafile import UnreadableFileError, FileTypeError
import beets.autotag.art
# Global logger.
log = logging.getLogger('beets')
# The list of default subcommands. This is populated with Subcommand
# objects that can be fed to a SubcommandsOptionParser.
default_commands = []
# import: Autotagger and importer.
DEFAULT_IMPORT_COPY = True
DEFAULT_IMPORT_WRITE = True
DEFAULT_IMPORT_AUTOT = True
DEFAULT_IMPORT_ART = True
def show_change(cur_artist, cur_album, items, info, dist):
"""Print out a representation of the changes that will be made if
tags are changed from (cur_artist, cur_album, items) to info with
distance dist.
"""
if cur_artist != info['artist'] or cur_album != info['album']:
print_("Correcting tags from:")
print_(' %s - %s' % (cur_artist or '', cur_album or ''))
print_("To:")
print_(' %s - %s' % (info['artist'], info['album']))
else:
print_("Tagging: %s - %s" % (info['artist'], info['album']))
print_('(Distance: %f)' % dist)
for i, (item, track_data) in enumerate(zip(items, info['tracks'])):
cur_track = item.track
new_track = i+1
if item.title != track_data['title'] and cur_track != new_track:
print_(" * %s (%i) -> %s (%i)" % (
item.title, cur_track, track_data['title'], new_track
))
elif item.title != track_data['title']:
print_(" * %s -> %s" % (item.title, track_data['title']))
elif cur_track != new_track:
print_(" * %s (%i -> %i)" % (item.title, cur_track, new_track))
CHOICE_SKIP = 'CHOICE_SKIP'
CHOICE_ASIS = 'CHOICE_ASIS'
CHOICE_MANUAL = 'CHOICE_MANUAL'
def choose_candidate(cur_artist, cur_album, candidates, rec):
"""Given current metadata and a sorted list of
(distance, candidate) pairs, ask the user for a selection
of which candidate to use. Returns the selected candidate.
If user chooses to skip, use as-is, or search manually, returns
CHOICE_SKIP, CHOICE_ASIS, or CHOICE_MANUAL.
"""
# Is the change good enough?
top_dist, _, _ = candidates[0]
bypass_candidates = False
if rec != autotag.RECOMMEND_NONE:
dist, items, info = candidates[0]
bypass_candidates = True
while True:
# Display and choose from candidates.
if not bypass_candidates:
print_('Finding tags for "%s - %s".' % (cur_artist, cur_album))
print_('Candidates:')
for i, (dist, items, info) in enumerate(candidates):
print_('%i. %s - %s (%f)' % (i+1, info['artist'],
info['album'], dist))
# Ask the user for a choice.
sel = ui.input_options(
'# selection (default 1), Skip, Use as-is, or '
'Enter manual search?',
('s', 'u', 'e'), '1',
'Enter a numerical selection, S, U, or E:',
(1, len(candidates))
)
if sel == 's':
return CHOICE_SKIP
elif sel == 'u':
return CHOICE_ASIS
elif sel == 'e':
return CHOICE_MANUAL
else: # Numerical selection.
dist, items, info = candidates[sel-1]
bypass_candidates = False
# Show what we're about to do.
show_change(cur_artist, cur_album, items, info, dist)
# Exact match => tag automatically.
if rec == autotag.RECOMMEND_STRONG:
return info
# Ask for confirmation.
sel = ui.input_options(
'[A]pply, More candidates, Skip, Use as-is, or '
'Enter manual search?',
('a', 'm', 's', 'u', 'e'), 'a',
'Enter A, M, S, U, or E:'
)
if sel == 'a':
return info
elif sel == 'm':
pass
elif sel == 's':
return CHOICE_SKIP
elif sel == 'u':
return CHOICE_ASIS
elif sel == 'e':
return CHOICE_MANUAL
def manual_search():
"""Input an artist and album for manual search."""
artist = raw_input('Artist: ')
album = raw_input('Album: ')
return artist.strip(), album.strip()
def tag_log(logfile, status, items):
"""Log a message about a given album to logfile. The status should
reflect the reason the album couldn't be tagged.
"""
if logfile:
path = os.path.commonprefix([item.path for item in items])
print >>logfile, status, os.path.dirname(path)
def tag_album(items, lib, copy=True, write=True, logfile=None, art=True):
"""Import items into lib, tagging them as an album. If copy, then
items are copied into the destination directory. If write, then
new metadata is written back to the files' tags. If logfile is
provided, then a log message will be added there if the album is
untaggable. If art, then attempt to download cover art for the
album.
"""
# Try to get candidate metadata.
search_artist, search_album = None, None
cur_artist, cur_album = None, None
while True:
# Infer tags.
try:
cur_artist, cur_album, candidates, rec = \
autotag.tag_album(items, search_artist, search_album)
except autotag.AutotagError:
cur_artist, cur_album, candidates, rec = None, None, None, None
info = None
else:
if candidates:
info = choose_candidate(cur_artist, cur_album, candidates, rec)
else:
info = None
# Fallback: if either an error ocurred or no matches found.
if not info:
print_("No match found for:", os.path.dirname(items[0].path))
sel = ui.input_options(
"[U]se as-is, Skip, or Enter manual search?",
('u', 's', 'e'), 'u',
'Enter U, S, or E:'
)
if sel == 'u':
info = CHOICE_ASIS
elif sel == 'e':
info = CHOICE_MANUAL
elif sel == 's':
info = CHOICE_SKIP
# Choose which tags to use.
if info is CHOICE_SKIP:
# Skip entirely.
tag_log(logfile, 'skip', items)
return
elif info is CHOICE_MANUAL:
# Try again with manual search terms.
search_artist, search_album = manual_search()
else:
# Either ASIS or we have a candidate. Continue tagging.
break
# Ensure that we don't have the album already.
if info is not CHOICE_ASIS or cur_artist is not None:
if info is CHOICE_ASIS:
artist = cur_artist
album = cur_album
tag_log(logfile, 'asis', items)
else:
artist = info['artist']
album = info['album']
q = library.AndQuery((library.MatchQuery('artist', artist),
library.MatchQuery('album', album)))
count, _ = q.count(lib)
if count >= 1:
print_("This album (%s - %s) is already in the library!" %
(artist, album))
return
# Change metadata, move, and copy.
if info is not CHOICE_ASIS:
autotag.apply_metadata(items, info)
for item in items:
if copy:
item.move(lib, True)
if write and info is not CHOICE_ASIS:
item.write()
# Add items to library. We consolidate this at the end to avoid
# locking while we do the copying and tag updates.
albuminfo = lib.add_album(items)
# Get album art if requested.
if art and info is not CHOICE_ASIS:
artpath = beets.autotag.art.art_for_album(info)
if artpath:
albuminfo.set_art(artpath)
def import_files(lib, paths, copy=True, write=True, autot=True,
logpath=None, art=True):
"""Import the files in the given list of paths, tagging each leaf
directory as an album. If copy, then the files are copied into
the library folder. If write, then new metadata is written to the
files themselves. If not autot, then just import the files
without attempting to tag. If logpath is provided, then untaggable
albums will be logged there. If art, then attempt to download
cover art for each album.
"""
# Open the log.
if logpath:
logfile = open(logpath, 'w')
else:
logfile = None
# Make sure we have only directories.
for path in paths:
if not os.path.isdir(path):
raise ui.UserError('not a directory: ' + path)
# Crawl albums and (optionally) tag them.
first = True
for path in paths:
for items in autotag.albums_in_dir(os.path.expanduser(path)):
if autot:
# Infer tags.
if not first:
print_()
first = False
tag_album(items, lib, copy, write, logfile, art)
else:
# No autotagging. Just add the album.
if copy:
for item in items:
item.move(lib, True)
lib.add_album(items)
# Write the database after each album.
lib.save()
# If we were logging, close the file.
if logfile:
logfile.close()
import_cmd = ui.Subcommand('import', help='import new music',
aliases=('imp', 'im'))
import_cmd.parser.add_option('-c', '--copy', action='store_true',
default=None, help="copy tracks into library directory (default)")
import_cmd.parser.add_option('-C', '--nocopy', action='store_false',
dest='copy', help="don't copy tracks (opposite of -c)")
import_cmd.parser.add_option('-w', '--write', action='store_true',
default=None, help="write new metadata to files' tags (default)")
import_cmd.parser.add_option('-W', '--nowrite', action='store_false',
dest='write', help="don't write metadata (opposite of -s)")
import_cmd.parser.add_option('-a', '--autotag', action='store_true',
dest='autotag', help="infer tags for imported files (default)")
import_cmd.parser.add_option('-A', '--noautotag', action='store_false',
dest='autotag',
help="don't infer tags for imported files (opposite of -a)")
import_cmd.parser.add_option('-r', '--art', action='store_true',
default=None, help="try to download album art")
import_cmd.parser.add_option('-R', '--noart', action='store_false',
dest='art', help="don't album art (opposite of -r)")
import_cmd.parser.add_option('-l', '--log', dest='logpath',
help='file to log untaggable albums for later review')
def import_func(lib, config, opts, args):
copy = opts.copy if opts.copy is not None else \
ui.config_val(config, 'beets', 'import_copy',
DEFAULT_IMPORT_COPY, bool)
write = opts.write if opts.write is not None else \
ui.config_val(config, 'beets', 'import_write',
DEFAULT_IMPORT_WRITE, bool)
autot = opts.autotag if opts.autotag is not None else DEFAULT_IMPORT_AUTOT
art = opts.art if opts.art is not None else \
ui.config_val(config, 'beets', 'import_art',
DEFAULT_IMPORT_ART, bool)
import_files(lib, args, copy, write, autot, opts.logpath, art)
import_cmd.func = import_func
default_commands.append(import_cmd)
# list: Query and show library contents.
def list_items(lib, query, album):
"""Print out items in lib matching query. If album, then search for
albums instead of single items.
"""
if album:
for album in lib.albums(query=query):
print_(album.artist + ' - ' + album.album)
else:
for item in lib.items(query=query):
print_(item.artist + ' - ' + item.album + ' - ' + item.title)
list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',))
list_cmd.parser.add_option('-a', '--album', action='store_true',
help='show matching albums instead of tracks')
def list_func(lib, config, opts, args):
list_items(lib, ui.make_query(args), opts.album)
list_cmd.func = list_func
default_commands.append(list_cmd)
# remove: Remove items from library, delete files.
def remove_items(lib, query, album, delete=False):
"""Remove items matching query from lib. If album, then match and
remove whole albums. If delete, also remove files from disk.
"""
# Get the matching items.
if album:
items = []
for album in lib.albums(query=query):
items += album.items()
else:
items = list(lib.items(query=query))
if not items:
print_('No matching items found.')
return
# Show all the items.
for item in items:
print_(item.artist + ' - ' + item.album + ' - ' + item.title)
# Confirm with user.
print_()
if delete:
prompt = 'Really DELETE %i files (y/n)?' % len(items)
else:
prompt = 'Really remove %i items from the library (y/n)?' % \
len(items)
if not ui.input_yn(prompt, True):
return
# Remove and delete.
for item in items:
lib.remove(item)
if delete:
os.unlink(item.path)
lib.save()
remove_cmd = ui.Subcommand('remove',
help='remove matching items from the library', aliases=('rm',))
remove_cmd.parser.add_option("-d", "--delete", action="store_true",
help="also remove files from disk")
remove_cmd.parser.add_option('-a', '--album', action='store_true',
help='match albums instead of tracks')
def remove_func(lib, config, opts, args):
remove_items(lib, ui.make_query(args), opts.album, opts.delete)
remove_cmd.func = remove_func
default_commands.append(remove_cmd)
# stats: Show library/query statistics.
def show_stats(lib, query):
"""Shows some statistics about the matched items."""
items = lib.items(query=query)
total_size = 0
total_time = 0.0
total_items = 0
artists = set()
albums = set()
for item in items:
#fixme This is approximate, so people might complain that
# this total size doesn't match "du -sh". Could fix this
# by putting total file size in the database.
total_size += int(item.length * item.bitrate / 8)
total_time += item.length
total_items += 1
artists.add(item.artist)
albums.add(item.album)
print_("""Tracks: %i
Total time: %s
Total size: %s
Artists: %i
Albums: %i""" % (
total_items,
ui.human_seconds(total_time),
ui.human_bytes(total_size),
len(artists), len(albums)
))
stats_cmd = ui.Subcommand('stats',
help='show statistics about the library or a query')
def stats_func(lib, config, opts, args):
show_stats(lib, ui.make_query(args))
stats_cmd.func = stats_func
default_commands.append(stats_cmd)