1# -*- coding: utf-8 -*-
2# This file is part of beets.
3# Copyright 2016, Adrian Sampson.
4#
5# Permission is hereby granted, free of charge, to any person obtaining
6# a copy of this software and associated documentation files (the
7# "Software"), to deal in the Software without restriction, including
8# without limitation the rights to use, copy, modify, merge, publish,
9# distribute, sublicense, and/or sell copies of the Software, and to
10# permit persons to whom the Software is furnished to do so, subject to
11# the following conditions:
12#
13# The above copyright notice and this permission notice shall be
14# included in all copies or substantial portions of the Software.
15
16"""This module provides the default commands for beets' command-line
17interface.
18"""
19
20from __future__ import division, absolute_import, print_function
21
22import os
23import re
24from platform import python_version
25from collections import namedtuple, Counter
26from itertools import chain
27
28import beets
29from beets import ui
30from beets.ui import print_, input_, decargs, show_path_changes
31from beets import autotag
32from beets.autotag import Recommendation
33from beets.autotag import hooks
34from beets import plugins
35from beets import importer
36from beets import util
37from beets.util import syspath, normpath, ancestry, displayable_path, \
38    MoveOperation
39from beets import library
40from beets import config
41from beets import logging
42from beets.util.confit import _package_path
43import six
44from . import _store_dict
45
46VARIOUS_ARTISTS = u'Various Artists'
47PromptChoice = namedtuple('PromptChoice', ['short', 'long', 'callback'])
48
49# Global logger.
50log = logging.getLogger('beets')
51
52# The list of default subcommands. This is populated with Subcommand
53# objects that can be fed to a SubcommandsOptionParser.
54default_commands = []
55
56
57# Utilities.
58
59def _do_query(lib, query, album, also_items=True):
60    """For commands that operate on matched items, performs a query
61    and returns a list of matching items and a list of matching
62    albums. (The latter is only nonempty when album is True.) Raises
63    a UserError if no items match. also_items controls whether, when
64    fetching albums, the associated items should be fetched also.
65    """
66    if album:
67        albums = list(lib.albums(query))
68        items = []
69        if also_items:
70            for al in albums:
71                items += al.items()
72
73    else:
74        albums = []
75        items = list(lib.items(query))
76
77    if album and not albums:
78        raise ui.UserError(u'No matching albums found.')
79    elif not album and not items:
80        raise ui.UserError(u'No matching items found.')
81
82    return items, albums
83
84
85# fields: Shows a list of available fields for queries and format strings.
86
87def _print_keys(query):
88    """Given a SQLite query result, print the `key` field of each
89    returned row, with indentation of 2 spaces.
90    """
91    for row in query:
92        print_(u' ' * 2 + row['key'])
93
94
95def fields_func(lib, opts, args):
96    def _print_rows(names):
97        names.sort()
98        print_(u'  ' + u'\n  '.join(names))
99
100    print_(u"Item fields:")
101    _print_rows(library.Item.all_keys())
102
103    print_(u"Album fields:")
104    _print_rows(library.Album.all_keys())
105
106    with lib.transaction() as tx:
107        # The SQL uses the DISTINCT to get unique values from the query
108        unique_fields = 'SELECT DISTINCT key FROM (%s)'
109
110        print_(u"Item flexible attributes:")
111        _print_keys(tx.query(unique_fields % library.Item._flex_table))
112
113        print_(u"Album flexible attributes:")
114        _print_keys(tx.query(unique_fields % library.Album._flex_table))
115
116fields_cmd = ui.Subcommand(
117    'fields',
118    help=u'show fields available for queries and format strings'
119)
120fields_cmd.func = fields_func
121default_commands.append(fields_cmd)
122
123
124# help: Print help text for commands
125
126class HelpCommand(ui.Subcommand):
127
128    def __init__(self):
129        super(HelpCommand, self).__init__(
130            'help', aliases=('?',),
131            help=u'give detailed help on a specific sub-command',
132        )
133
134    def func(self, lib, opts, args):
135        if args:
136            cmdname = args[0]
137            helpcommand = self.root_parser._subcommand_for_name(cmdname)
138            if not helpcommand:
139                raise ui.UserError(u"unknown command '{0}'".format(cmdname))
140            helpcommand.print_help()
141        else:
142            self.root_parser.print_help()
143
144
145default_commands.append(HelpCommand())
146
147
148# import: Autotagger and importer.
149
150# Importer utilities and support.
151
152def disambig_string(info):
153    """Generate a string for an AlbumInfo or TrackInfo object that
154    provides context that helps disambiguate similar-looking albums and
155    tracks.
156    """
157    disambig = []
158    if info.data_source and info.data_source != 'MusicBrainz':
159        disambig.append(info.data_source)
160
161    if isinstance(info, hooks.AlbumInfo):
162        if info.media:
163            if info.mediums and info.mediums > 1:
164                disambig.append(u'{0}x{1}'.format(
165                    info.mediums, info.media
166                ))
167            else:
168                disambig.append(info.media)
169        if info.year:
170            disambig.append(six.text_type(info.year))
171        if info.country:
172            disambig.append(info.country)
173        if info.label:
174            disambig.append(info.label)
175        if info.catalognum:
176            disambig.append(info.catalognum)
177        if info.albumdisambig:
178            disambig.append(info.albumdisambig)
179
180    if disambig:
181        return u', '.join(disambig)
182
183
184def dist_string(dist):
185    """Formats a distance (a float) as a colorized similarity percentage
186    string.
187    """
188    out = u'%.1f%%' % ((1 - dist) * 100)
189    if dist <= config['match']['strong_rec_thresh'].as_number():
190        out = ui.colorize('text_success', out)
191    elif dist <= config['match']['medium_rec_thresh'].as_number():
192        out = ui.colorize('text_warning', out)
193    else:
194        out = ui.colorize('text_error', out)
195    return out
196
197
198def penalty_string(distance, limit=None):
199    """Returns a colorized string that indicates all the penalties
200    applied to a distance object.
201    """
202    penalties = []
203    for key in distance.keys():
204        key = key.replace('album_', '')
205        key = key.replace('track_', '')
206        key = key.replace('_', ' ')
207        penalties.append(key)
208    if penalties:
209        if limit and len(penalties) > limit:
210            penalties = penalties[:limit] + ['...']
211        return ui.colorize('text_warning', u'(%s)' % ', '.join(penalties))
212
213
214def show_change(cur_artist, cur_album, match):
215    """Print out a representation of the changes that will be made if an
216    album's tags are changed according to `match`, which must be an AlbumMatch
217    object.
218    """
219    def show_album(artist, album):
220        if artist:
221            album_description = u'    %s - %s' % (artist, album)
222        elif album:
223            album_description = u'    %s' % album
224        else:
225            album_description = u'    (unknown album)'
226        print_(album_description)
227
228    def format_index(track_info):
229        """Return a string representing the track index of the given
230        TrackInfo or Item object.
231        """
232        if isinstance(track_info, hooks.TrackInfo):
233            index = track_info.index
234            medium_index = track_info.medium_index
235            medium = track_info.medium
236            mediums = match.info.mediums
237        else:
238            index = medium_index = track_info.track
239            medium = track_info.disc
240            mediums = track_info.disctotal
241        if config['per_disc_numbering']:
242            if mediums and mediums > 1:
243                return u'{0}-{1}'.format(medium, medium_index)
244            else:
245                return six.text_type(medium_index or index)
246        else:
247            return six.text_type(index)
248
249    # Identify the album in question.
250    if cur_artist != match.info.artist or \
251            (cur_album != match.info.album and
252             match.info.album != VARIOUS_ARTISTS):
253        artist_l, artist_r = cur_artist or '', match.info.artist
254        album_l,  album_r = cur_album or '', match.info.album
255        if artist_r == VARIOUS_ARTISTS:
256            # Hide artists for VA releases.
257            artist_l, artist_r = u'', u''
258
259        if config['artist_credit']:
260            artist_r = match.info.artist_credit
261
262        artist_l, artist_r = ui.colordiff(artist_l, artist_r)
263        album_l, album_r = ui.colordiff(album_l, album_r)
264
265        print_(u"Correcting tags from:")
266        show_album(artist_l, album_l)
267        print_(u"To:")
268        show_album(artist_r, album_r)
269    else:
270        print_(u"Tagging:\n    {0.artist} - {0.album}".format(match.info))
271
272    # Data URL.
273    if match.info.data_url:
274        print_(u'URL:\n    %s' % match.info.data_url)
275
276    # Info line.
277    info = []
278    # Similarity.
279    info.append(u'(Similarity: %s)' % dist_string(match.distance))
280    # Penalties.
281    penalties = penalty_string(match.distance)
282    if penalties:
283        info.append(penalties)
284    # Disambiguation.
285    disambig = disambig_string(match.info)
286    if disambig:
287        info.append(ui.colorize('text_highlight_minor', u'(%s)' % disambig))
288    print_(' '.join(info))
289
290    # Tracks.
291    pairs = list(match.mapping.items())
292    pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index)
293
294    # Build up LHS and RHS for track difference display. The `lines` list
295    # contains ``(lhs, rhs, width)`` tuples where `width` is the length (in
296    # characters) of the uncolorized LHS.
297    lines = []
298    medium = disctitle = None
299    for item, track_info in pairs:
300
301        # Medium number and title.
302        if medium != track_info.medium or disctitle != track_info.disctitle:
303            media = match.info.media or 'Media'
304            if match.info.mediums > 1 and track_info.disctitle:
305                lhs = u'%s %s: %s' % (media, track_info.medium,
306                                      track_info.disctitle)
307            elif match.info.mediums > 1:
308                lhs = u'%s %s' % (media, track_info.medium)
309            elif track_info.disctitle:
310                lhs = u'%s: %s' % (media, track_info.disctitle)
311            else:
312                lhs = None
313            if lhs:
314                lines.append((lhs, u'', 0))
315            medium, disctitle = track_info.medium, track_info.disctitle
316
317        # Titles.
318        new_title = track_info.title
319        if not item.title.strip():
320            # If there's no title, we use the filename.
321            cur_title = displayable_path(os.path.basename(item.path))
322            lhs, rhs = cur_title, new_title
323        else:
324            cur_title = item.title.strip()
325            lhs, rhs = ui.colordiff(cur_title, new_title)
326        lhs_width = len(cur_title)
327
328        # Track number change.
329        cur_track, new_track = format_index(item), format_index(track_info)
330        if cur_track != new_track:
331            if item.track in (track_info.index, track_info.medium_index):
332                color = 'text_highlight_minor'
333            else:
334                color = 'text_highlight'
335            templ = ui.colorize(color, u' (#{0})')
336            lhs += templ.format(cur_track)
337            rhs += templ.format(new_track)
338            lhs_width += len(cur_track) + 4
339
340        # Length change.
341        if item.length and track_info.length and \
342                abs(item.length - track_info.length) > \
343                config['ui']['length_diff_thresh'].as_number():
344            cur_length = ui.human_seconds_short(item.length)
345            new_length = ui.human_seconds_short(track_info.length)
346            templ = ui.colorize('text_highlight', u' ({0})')
347            lhs += templ.format(cur_length)
348            rhs += templ.format(new_length)
349            lhs_width += len(cur_length) + 3
350
351        # Penalties.
352        penalties = penalty_string(match.distance.tracks[track_info])
353        if penalties:
354            rhs += ' %s' % penalties
355
356        if lhs != rhs:
357            lines.append((u' * %s' % lhs, rhs, lhs_width))
358        elif config['import']['detail']:
359            lines.append((u' * %s' % lhs, '', lhs_width))
360
361    # Print each track in two columns, or across two lines.
362    col_width = (ui.term_width() - len(''.join([' * ', ' -> ']))) // 2
363    if lines:
364        max_width = max(w for _, _, w in lines)
365        for lhs, rhs, lhs_width in lines:
366            if not rhs:
367                print_(lhs)
368            elif max_width > col_width:
369                print_(u'%s ->\n   %s' % (lhs, rhs))
370            else:
371                pad = max_width - lhs_width
372                print_(u'%s%s -> %s' % (lhs, ' ' * pad, rhs))
373
374    # Missing and unmatched tracks.
375    if match.extra_tracks:
376        print_(u'Missing tracks ({0}/{1} - {2:.1%}):'.format(
377               len(match.extra_tracks),
378               len(match.info.tracks),
379               len(match.extra_tracks) / len(match.info.tracks)
380               ))
381        pad_width = max(len(track_info.title) for track_info in
382                        match.extra_tracks)
383    for track_info in match.extra_tracks:
384        line = u' ! {0: <{width}} (#{1: >2})'.format(track_info.title,
385                                                     format_index(track_info),
386                                                     width=pad_width)
387        if track_info.length:
388            line += u' (%s)' % ui.human_seconds_short(track_info.length)
389        print_(ui.colorize('text_warning', line))
390    if match.extra_items:
391        print_(u'Unmatched tracks ({0}):'.format(len(match.extra_items)))
392        pad_width = max(len(item.title) for item in match.extra_items)
393    for item in match.extra_items:
394        line = u' ! {0: <{width}} (#{1: >2})'.format(item.title,
395                                                     format_index(item),
396                                                     width=pad_width)
397        if item.length:
398            line += u' (%s)' % ui.human_seconds_short(item.length)
399        print_(ui.colorize('text_warning', line))
400
401
402def show_item_change(item, match):
403    """Print out the change that would occur by tagging `item` with the
404    metadata from `match`, a TrackMatch object.
405    """
406    cur_artist, new_artist = item.artist, match.info.artist
407    cur_title, new_title = item.title, match.info.title
408
409    if cur_artist != new_artist or cur_title != new_title:
410        cur_artist, new_artist = ui.colordiff(cur_artist, new_artist)
411        cur_title, new_title = ui.colordiff(cur_title, new_title)
412
413        print_(u"Correcting track tags from:")
414        print_(u"    %s - %s" % (cur_artist, cur_title))
415        print_(u"To:")
416        print_(u"    %s - %s" % (new_artist, new_title))
417
418    else:
419        print_(u"Tagging track: %s - %s" % (cur_artist, cur_title))
420
421    # Data URL.
422    if match.info.data_url:
423        print_(u'URL:\n    %s' % match.info.data_url)
424
425    # Info line.
426    info = []
427    # Similarity.
428    info.append(u'(Similarity: %s)' % dist_string(match.distance))
429    # Penalties.
430    penalties = penalty_string(match.distance)
431    if penalties:
432        info.append(penalties)
433    # Disambiguation.
434    disambig = disambig_string(match.info)
435    if disambig:
436        info.append(ui.colorize('text_highlight_minor', u'(%s)' % disambig))
437    print_(' '.join(info))
438
439
440def summarize_items(items, singleton):
441    """Produces a brief summary line describing a set of items. Used for
442    manually resolving duplicates during import.
443
444    `items` is a list of `Item` objects. `singleton` indicates whether
445    this is an album or single-item import (if the latter, them `items`
446    should only have one element).
447    """
448    summary_parts = []
449    if not singleton:
450        summary_parts.append(u"{0} items".format(len(items)))
451
452    format_counts = {}
453    for item in items:
454        format_counts[item.format] = format_counts.get(item.format, 0) + 1
455    if len(format_counts) == 1:
456        # A single format.
457        summary_parts.append(items[0].format)
458    else:
459        # Enumerate all the formats by decreasing frequencies:
460        for fmt, count in sorted(
461            format_counts.items(),
462            key=lambda fmt_and_count: (-fmt_and_count[1], fmt_and_count[0])
463        ):
464            summary_parts.append('{0} {1}'.format(fmt, count))
465
466    if items:
467        average_bitrate = sum([item.bitrate for item in items]) / len(items)
468        total_duration = sum([item.length for item in items])
469        total_filesize = sum([item.filesize for item in items])
470        summary_parts.append(u'{0}kbps'.format(int(average_bitrate / 1000)))
471        summary_parts.append(ui.human_seconds_short(total_duration))
472        summary_parts.append(ui.human_bytes(total_filesize))
473
474    return u', '.join(summary_parts)
475
476
477def _summary_judgment(rec):
478    """Determines whether a decision should be made without even asking
479    the user. This occurs in quiet mode and when an action is chosen for
480    NONE recommendations. Return an action or None if the user should be
481    queried. May also print to the console if a summary judgment is
482    made.
483    """
484    if config['import']['quiet']:
485        if rec == Recommendation.strong:
486            return importer.action.APPLY
487        else:
488            action = config['import']['quiet_fallback'].as_choice({
489                'skip': importer.action.SKIP,
490                'asis': importer.action.ASIS,
491            })
492
493    elif rec == Recommendation.none:
494        action = config['import']['none_rec_action'].as_choice({
495            'skip': importer.action.SKIP,
496            'asis': importer.action.ASIS,
497            'ask': None,
498        })
499
500    else:
501        return None
502
503    if action == importer.action.SKIP:
504        print_(u'Skipping.')
505    elif action == importer.action.ASIS:
506        print_(u'Importing as-is.')
507    return action
508
509
510def choose_candidate(candidates, singleton, rec, cur_artist=None,
511                     cur_album=None, item=None, itemcount=None,
512                     choices=[]):
513    """Given a sorted list of candidates, ask the user for a selection
514    of which candidate to use. Applies to both full albums and
515    singletons  (tracks). Candidates are either AlbumMatch or TrackMatch
516    objects depending on `singleton`. for albums, `cur_artist`,
517    `cur_album`, and `itemcount` must be provided. For singletons,
518    `item` must be provided.
519
520    `choices` is a list of `PromptChoice`s to be used in each prompt.
521
522    Returns one of the following:
523    * the result of the choice, which may be SKIP or ASIS
524    * a candidate (an AlbumMatch/TrackMatch object)
525    * a chosen `PromptChoice` from `choices`
526    """
527    # Sanity check.
528    if singleton:
529        assert item is not None
530    else:
531        assert cur_artist is not None
532        assert cur_album is not None
533
534    # Build helper variables for the prompt choices.
535    choice_opts = tuple(c.long for c in choices)
536    choice_actions = {c.short: c for c in choices}
537
538    # Zero candidates.
539    if not candidates:
540        if singleton:
541            print_(u"No matching recordings found.")
542        else:
543            print_(u"No matching release found for {0} tracks."
544                   .format(itemcount))
545            print_(u'For help, see: '
546                   u'http://beets.readthedocs.org/en/latest/faq.html#nomatch')
547        sel = ui.input_options(choice_opts)
548        if sel in choice_actions:
549            return choice_actions[sel]
550        else:
551            assert False
552
553    # Is the change good enough?
554    bypass_candidates = False
555    if rec != Recommendation.none:
556        match = candidates[0]
557        bypass_candidates = True
558
559    while True:
560        # Display and choose from candidates.
561        require = rec <= Recommendation.low
562
563        if not bypass_candidates:
564            # Display list of candidates.
565            print_(u'Finding tags for {0} "{1} - {2}".'.format(
566                u'track' if singleton else u'album',
567                item.artist if singleton else cur_artist,
568                item.title if singleton else cur_album,
569            ))
570
571            print_(u'Candidates:')
572            for i, match in enumerate(candidates):
573                # Index, metadata, and distance.
574                line = [
575                    u'{0}.'.format(i + 1),
576                    u'{0} - {1}'.format(
577                        match.info.artist,
578                        match.info.title if singleton else match.info.album,
579                    ),
580                    u'({0})'.format(dist_string(match.distance)),
581                ]
582
583                # Penalties.
584                penalties = penalty_string(match.distance, 3)
585                if penalties:
586                    line.append(penalties)
587
588                # Disambiguation
589                disambig = disambig_string(match.info)
590                if disambig:
591                    line.append(ui.colorize('text_highlight_minor',
592                                            u'(%s)' % disambig))
593
594                print_(u' '.join(line))
595
596            # Ask the user for a choice.
597            sel = ui.input_options(choice_opts,
598                                   numrange=(1, len(candidates)))
599            if sel == u'm':
600                pass
601            elif sel in choice_actions:
602                return choice_actions[sel]
603            else:  # Numerical selection.
604                match = candidates[sel - 1]
605                if sel != 1:
606                    # When choosing anything but the first match,
607                    # disable the default action.
608                    require = True
609        bypass_candidates = False
610
611        # Show what we're about to do.
612        if singleton:
613            show_item_change(item, match)
614        else:
615            show_change(cur_artist, cur_album, match)
616
617        # Exact match => tag automatically if we're not in timid mode.
618        if rec == Recommendation.strong and not config['import']['timid']:
619            return match
620
621        # Ask for confirmation.
622        default = config['import']['default_action'].as_choice({
623            u'apply': u'a',
624            u'skip': u's',
625            u'asis': u'u',
626            u'none': None,
627        })
628        if default is None:
629            require = True
630        # Bell ring when user interaction is needed.
631        if config['import']['bell']:
632            ui.print_(u'\a', end=u'')
633        sel = ui.input_options((u'Apply', u'More candidates') + choice_opts,
634                               require=require, default=default)
635        if sel == u'a':
636            return match
637        elif sel in choice_actions:
638            return choice_actions[sel]
639
640
641def manual_search(session, task):
642    """Get a new `Proposal` using manual search criteria.
643
644    Input either an artist and album (for full albums) or artist and
645    track name (for singletons) for manual search.
646    """
647    artist = input_(u'Artist:').strip()
648    name = input_(u'Album:' if task.is_album else u'Track:').strip()
649
650    if task.is_album:
651        _, _, prop = autotag.tag_album(
652            task.items, artist, name
653        )
654        return prop
655    else:
656        return autotag.tag_item(task.item, artist, name)
657
658
659def manual_id(session, task):
660    """Get a new `Proposal` using a manually-entered ID.
661
662    Input an ID, either for an album ("release") or a track ("recording").
663    """
664    prompt = u'Enter {0} ID:'.format(u'release' if task.is_album
665                                     else u'recording')
666    search_id = input_(prompt).strip()
667
668    if task.is_album:
669        _, _, prop = autotag.tag_album(
670            task.items, search_ids=search_id.split()
671        )
672        return prop
673    else:
674        return autotag.tag_item(task.item, search_ids=search_id.split())
675
676
677def abort_action(session, task):
678    """A prompt choice callback that aborts the importer.
679    """
680    raise importer.ImportAbort()
681
682
683class TerminalImportSession(importer.ImportSession):
684    """An import session that runs in a terminal.
685    """
686    def choose_match(self, task):
687        """Given an initial autotagging of items, go through an interactive
688        dance with the user to ask for a choice of metadata. Returns an
689        AlbumMatch object, ASIS, or SKIP.
690        """
691        # Show what we're tagging.
692        print_()
693        print_(displayable_path(task.paths, u'\n') +
694               u' ({0} items)'.format(len(task.items)))
695
696        # Take immediate action if appropriate.
697        action = _summary_judgment(task.rec)
698        if action == importer.action.APPLY:
699            match = task.candidates[0]
700            show_change(task.cur_artist, task.cur_album, match)
701            return match
702        elif action is not None:
703            return action
704
705        # Loop until we have a choice.
706        while True:
707            # Ask for a choice from the user. The result of
708            # `choose_candidate` may be an `importer.action`, an
709            # `AlbumMatch` object for a specific selection, or a
710            # `PromptChoice`.
711            choices = self._get_choices(task)
712            choice = choose_candidate(
713                task.candidates, False, task.rec, task.cur_artist,
714                task.cur_album, itemcount=len(task.items), choices=choices
715            )
716
717            # Basic choices that require no more action here.
718            if choice in (importer.action.SKIP, importer.action.ASIS):
719                # Pass selection to main control flow.
720                return choice
721
722            # Plugin-provided choices. We invoke the associated callback
723            # function.
724            elif choice in choices:
725                post_choice = choice.callback(self, task)
726                if isinstance(post_choice, importer.action):
727                    return post_choice
728                elif isinstance(post_choice, autotag.Proposal):
729                    # Use the new candidates and continue around the loop.
730                    task.candidates = post_choice.candidates
731                    task.rec = post_choice.recommendation
732
733            # Otherwise, we have a specific match selection.
734            else:
735                # We have a candidate! Finish tagging. Here, choice is an
736                # AlbumMatch object.
737                assert isinstance(choice, autotag.AlbumMatch)
738                return choice
739
740    def choose_item(self, task):
741        """Ask the user for a choice about tagging a single item. Returns
742        either an action constant or a TrackMatch object.
743        """
744        print_()
745        print_(displayable_path(task.item.path))
746        candidates, rec = task.candidates, task.rec
747
748        # Take immediate action if appropriate.
749        action = _summary_judgment(task.rec)
750        if action == importer.action.APPLY:
751            match = candidates[0]
752            show_item_change(task.item, match)
753            return match
754        elif action is not None:
755            return action
756
757        while True:
758            # Ask for a choice.
759            choices = self._get_choices(task)
760            choice = choose_candidate(candidates, True, rec, item=task.item,
761                                      choices=choices)
762
763            if choice in (importer.action.SKIP, importer.action.ASIS):
764                return choice
765
766            elif choice in choices:
767                post_choice = choice.callback(self, task)
768                if isinstance(post_choice, importer.action):
769                    return post_choice
770                elif isinstance(post_choice, autotag.Proposal):
771                    candidates = post_choice.candidates
772                    rec = post_choice.recommendation
773
774            else:
775                # Chose a candidate.
776                assert isinstance(choice, autotag.TrackMatch)
777                return choice
778
779    def resolve_duplicate(self, task, found_duplicates):
780        """Decide what to do when a new album or item seems similar to one
781        that's already in the library.
782        """
783        log.warning(u"This {0} is already in the library!",
784                    (u"album" if task.is_album else u"item"))
785
786        if config['import']['quiet']:
787            # In quiet mode, don't prompt -- just skip.
788            log.info(u'Skipping.')
789            sel = u's'
790        else:
791            # Print some detail about the existing and new items so the
792            # user can make an informed decision.
793            for duplicate in found_duplicates:
794                print_(u"Old: " + summarize_items(
795                    list(duplicate.items()) if task.is_album else [duplicate],
796                    not task.is_album,
797                ))
798
799            print_(u"New: " + summarize_items(
800                task.imported_items(),
801                not task.is_album,
802            ))
803
804            sel = ui.input_options(
805                (u'Skip new', u'Keep both', u'Remove old', u'Merge all')
806            )
807
808        if sel == u's':
809            # Skip new.
810            task.set_choice(importer.action.SKIP)
811        elif sel == u'k':
812            # Keep both. Do nothing; leave the choice intact.
813            pass
814        elif sel == u'r':
815            # Remove old.
816            task.should_remove_duplicates = True
817        elif sel == u'm':
818            task.should_merge_duplicates = True
819        else:
820            assert False
821
822    def should_resume(self, path):
823        return ui.input_yn(u"Import of the directory:\n{0}\n"
824                           u"was interrupted. Resume (Y/n)?"
825                           .format(displayable_path(path)))
826
827    def _get_choices(self, task):
828        """Get the list of prompt choices that should be presented to the
829        user. This consists of both built-in choices and ones provided by
830        plugins.
831
832        The `before_choose_candidate` event is sent to the plugins, with
833        session and task as its parameters. Plugins are responsible for
834        checking the right conditions and returning a list of `PromptChoice`s,
835        which is flattened and checked for conflicts.
836
837        If two or more choices have the same short letter, a warning is
838        emitted and all but one choices are discarded, giving preference
839        to the default importer choices.
840
841        Returns a list of `PromptChoice`s.
842        """
843        # Standard, built-in choices.
844        choices = [
845            PromptChoice(u's', u'Skip',
846                         lambda s, t: importer.action.SKIP),
847            PromptChoice(u'u', u'Use as-is',
848                         lambda s, t: importer.action.ASIS)
849        ]
850        if task.is_album:
851            choices += [
852                PromptChoice(u't', u'as Tracks',
853                             lambda s, t: importer.action.TRACKS),
854                PromptChoice(u'g', u'Group albums',
855                             lambda s, t: importer.action.ALBUMS),
856            ]
857        choices += [
858            PromptChoice(u'e', u'Enter search', manual_search),
859            PromptChoice(u'i', u'enter Id', manual_id),
860            PromptChoice(u'b', u'aBort', abort_action),
861        ]
862
863        # Send the before_choose_candidate event and flatten list.
864        extra_choices = list(chain(*plugins.send('before_choose_candidate',
865                                                 session=self, task=task)))
866
867        # Add a "dummy" choice for the other baked-in option, for
868        # duplicate checking.
869        all_choices = [
870            PromptChoice(u'a', u'Apply', None),
871        ] + choices + extra_choices
872
873        # Check for conflicts.
874        short_letters = [c.short for c in all_choices]
875        if len(short_letters) != len(set(short_letters)):
876            # Duplicate short letter has been found.
877            duplicates = [i for i, count in Counter(short_letters).items()
878                          if count > 1]
879            for short in duplicates:
880                # Keep the first of the choices, removing the rest.
881                dup_choices = [c for c in all_choices if c.short == short]
882                for c in dup_choices[1:]:
883                    log.warning(u"Prompt choice '{0}' removed due to conflict "
884                                u"with '{1}' (short letter: '{2}')",
885                                c.long, dup_choices[0].long, c.short)
886                    extra_choices.remove(c)
887
888        return choices + extra_choices
889
890
891# The import command.
892
893
894def import_files(lib, paths, query):
895    """Import the files in the given list of paths or matching the
896    query.
897    """
898    # Check the user-specified directories.
899    for path in paths:
900        if not os.path.exists(syspath(normpath(path))):
901            raise ui.UserError(u'no such file or directory: {0}'.format(
902                displayable_path(path)))
903
904    # Check parameter consistency.
905    if config['import']['quiet'] and config['import']['timid']:
906        raise ui.UserError(u"can't be both quiet and timid")
907
908    # Open the log.
909    if config['import']['log'].get() is not None:
910        logpath = syspath(config['import']['log'].as_filename())
911        try:
912            loghandler = logging.FileHandler(logpath)
913        except IOError:
914            raise ui.UserError(u"could not open log file for writing: "
915                               u"{0}".format(displayable_path(logpath)))
916    else:
917        loghandler = None
918
919    # Never ask for input in quiet mode.
920    if config['import']['resume'].get() == 'ask' and \
921            config['import']['quiet']:
922        config['import']['resume'] = False
923
924    session = TerminalImportSession(lib, loghandler, paths, query)
925    session.run()
926
927    # Emit event.
928    plugins.send('import', lib=lib, paths=paths)
929
930
931def import_func(lib, opts, args):
932    config['import'].set_args(opts)
933
934    # Special case: --copy flag suppresses import_move (which would
935    # otherwise take precedence).
936    if opts.copy:
937        config['import']['move'] = False
938
939    if opts.library:
940        query = decargs(args)
941        paths = []
942    else:
943        query = None
944        paths = args
945        if not paths:
946            raise ui.UserError(u'no path specified')
947
948        # On Python 2, we get filenames as raw bytes, which is what we
949        # need. On Python 3, we need to undo the "helpful" conversion to
950        # Unicode strings to get the real bytestring filename.
951        if not six.PY2:
952            paths = [p.encode(util.arg_encoding(), 'surrogateescape')
953                     for p in paths]
954
955    import_files(lib, paths, query)
956
957
958import_cmd = ui.Subcommand(
959    u'import', help=u'import new music', aliases=(u'imp', u'im')
960)
961import_cmd.parser.add_option(
962    u'-c', u'--copy', action='store_true', default=None,
963    help=u"copy tracks into library directory (default)"
964)
965import_cmd.parser.add_option(
966    u'-C', u'--nocopy', action='store_false', dest='copy',
967    help=u"don't copy tracks (opposite of -c)"
968)
969import_cmd.parser.add_option(
970    u'-m', u'--move', action='store_true', dest='move',
971    help=u"move tracks into the library (overrides -c)"
972)
973import_cmd.parser.add_option(
974    u'-w', u'--write', action='store_true', default=None,
975    help=u"write new metadata to files' tags (default)"
976)
977import_cmd.parser.add_option(
978    u'-W', u'--nowrite', action='store_false', dest='write',
979    help=u"don't write metadata (opposite of -w)"
980)
981import_cmd.parser.add_option(
982    u'-a', u'--autotag', action='store_true', dest='autotag',
983    help=u"infer tags for imported files (default)"
984)
985import_cmd.parser.add_option(
986    u'-A', u'--noautotag', action='store_false', dest='autotag',
987    help=u"don't infer tags for imported files (opposite of -a)"
988)
989import_cmd.parser.add_option(
990    u'-p', u'--resume', action='store_true', default=None,
991    help=u"resume importing if interrupted"
992)
993import_cmd.parser.add_option(
994    u'-P', u'--noresume', action='store_false', dest='resume',
995    help=u"do not try to resume importing"
996)
997import_cmd.parser.add_option(
998    u'-q', u'--quiet', action='store_true', dest='quiet',
999    help=u"never prompt for input: skip albums instead"
1000)
1001import_cmd.parser.add_option(
1002    u'-l', u'--log', dest='log',
1003    help=u'file to log untaggable albums for later review'
1004)
1005import_cmd.parser.add_option(
1006    u'-s', u'--singletons', action='store_true',
1007    help=u'import individual tracks instead of full albums'
1008)
1009import_cmd.parser.add_option(
1010    u'-t', u'--timid', dest='timid', action='store_true',
1011    help=u'always confirm all actions'
1012)
1013import_cmd.parser.add_option(
1014    u'-L', u'--library', dest='library', action='store_true',
1015    help=u'retag items matching a query'
1016)
1017import_cmd.parser.add_option(
1018    u'-i', u'--incremental', dest='incremental', action='store_true',
1019    help=u'skip already-imported directories'
1020)
1021import_cmd.parser.add_option(
1022    u'-I', u'--noincremental', dest='incremental', action='store_false',
1023    help=u'do not skip already-imported directories'
1024)
1025import_cmd.parser.add_option(
1026    u'--from-scratch', dest='from_scratch', action='store_true',
1027    help=u'erase existing metadata before applying new metadata'
1028)
1029import_cmd.parser.add_option(
1030    u'--flat', dest='flat', action='store_true',
1031    help=u'import an entire tree as a single album'
1032)
1033import_cmd.parser.add_option(
1034    u'-g', u'--group-albums', dest='group_albums', action='store_true',
1035    help=u'group tracks in a folder into separate albums'
1036)
1037import_cmd.parser.add_option(
1038    u'--pretend', dest='pretend', action='store_true',
1039    help=u'just print the files to import'
1040)
1041import_cmd.parser.add_option(
1042    u'-S', u'--search-id', dest='search_ids', action='append',
1043    metavar='ID',
1044    help=u'restrict matching to a specific metadata backend ID'
1045)
1046import_cmd.parser.add_option(
1047    u'--set', dest='set_fields', action='callback',
1048    callback=_store_dict,
1049    metavar='FIELD=VALUE',
1050    help=u'set the given fields to the supplied values'
1051)
1052import_cmd.func = import_func
1053default_commands.append(import_cmd)
1054
1055
1056# list: Query and show library contents.
1057
1058def list_items(lib, query, album, fmt=u''):
1059    """Print out items in lib matching query. If album, then search for
1060    albums instead of single items.
1061    """
1062    if album:
1063        for album in lib.albums(query):
1064            ui.print_(format(album, fmt))
1065    else:
1066        for item in lib.items(query):
1067            ui.print_(format(item, fmt))
1068
1069
1070def list_func(lib, opts, args):
1071    list_items(lib, decargs(args), opts.album)
1072
1073
1074list_cmd = ui.Subcommand(u'list', help=u'query the library', aliases=(u'ls',))
1075list_cmd.parser.usage += u"\n" \
1076    u'Example: %prog -f \'$album: $title\' artist:beatles'
1077list_cmd.parser.add_all_common_options()
1078list_cmd.func = list_func
1079default_commands.append(list_cmd)
1080
1081
1082# update: Update library contents according to on-disk tags.
1083
1084def update_items(lib, query, album, move, pretend, fields):
1085    """For all the items matched by the query, update the library to
1086    reflect the item's embedded tags.
1087    :param fields: The fields to be stored. If not specified, all fields will
1088    be.
1089    """
1090    with lib.transaction():
1091        if move and fields is not None and 'path' not in fields:
1092            # Special case: if an item needs to be moved, the path field has to
1093            # updated; otherwise the new path will not be reflected in the
1094            # database.
1095            fields.append('path')
1096        items, _ = _do_query(lib, query, album)
1097
1098        # Walk through the items and pick up their changes.
1099        affected_albums = set()
1100        for item in items:
1101            # Item deleted?
1102            if not os.path.exists(syspath(item.path)):
1103                ui.print_(format(item))
1104                ui.print_(ui.colorize('text_error', u'  deleted'))
1105                if not pretend:
1106                    item.remove(True)
1107                affected_albums.add(item.album_id)
1108                continue
1109
1110            # Did the item change since last checked?
1111            if item.current_mtime() <= item.mtime:
1112                log.debug(u'skipping {0} because mtime is up to date ({1})',
1113                          displayable_path(item.path), item.mtime)
1114                continue
1115
1116            # Read new data.
1117            try:
1118                item.read()
1119            except library.ReadError as exc:
1120                log.error(u'error reading {0}: {1}',
1121                          displayable_path(item.path), exc)
1122                continue
1123
1124            # Special-case album artist when it matches track artist. (Hacky
1125            # but necessary for preserving album-level metadata for non-
1126            # autotagged imports.)
1127            if not item.albumartist:
1128                old_item = lib.get_item(item.id)
1129                if old_item.albumartist == old_item.artist == item.artist:
1130                    item.albumartist = old_item.albumartist
1131                    item._dirty.discard(u'albumartist')
1132
1133            # Check for and display changes.
1134            changed = ui.show_model_changes(
1135                item,
1136                fields=fields or library.Item._media_fields)
1137
1138            # Save changes.
1139            if not pretend:
1140                if changed:
1141                    # Move the item if it's in the library.
1142                    if move and lib.directory in ancestry(item.path):
1143                        item.move(store=False)
1144
1145                    item.store(fields=fields)
1146                    affected_albums.add(item.album_id)
1147                else:
1148                    # The file's mtime was different, but there were no
1149                    # changes to the metadata. Store the new mtime,
1150                    # which is set in the call to read(), so we don't
1151                    # check this again in the future.
1152                    item.store(fields=fields)
1153
1154        # Skip album changes while pretending.
1155        if pretend:
1156            return
1157
1158        # Modify affected albums to reflect changes in their items.
1159        for album_id in affected_albums:
1160            if album_id is None:  # Singletons.
1161                continue
1162            album = lib.get_album(album_id)
1163            if not album:  # Empty albums have already been removed.
1164                log.debug(u'emptied album {0}', album_id)
1165                continue
1166            first_item = album.items().get()
1167
1168            # Update album structure to reflect an item in it.
1169            for key in library.Album.item_keys:
1170                album[key] = first_item[key]
1171            album.store(fields=fields)
1172
1173            # Move album art (and any inconsistent items).
1174            if move and lib.directory in ancestry(first_item.path):
1175                log.debug(u'moving album {0}', album_id)
1176
1177                # Manually moving and storing the album.
1178                items = list(album.items())
1179                for item in items:
1180                    item.move(store=False, with_album=False)
1181                    item.store(fields=fields)
1182                album.move(store=False)
1183                album.store(fields=fields)
1184
1185
1186def update_func(lib, opts, args):
1187    update_items(lib, decargs(args), opts.album, ui.should_move(opts.move),
1188                 opts.pretend, opts.fields)
1189
1190
1191update_cmd = ui.Subcommand(
1192    u'update', help=u'update the library', aliases=(u'upd', u'up',)
1193)
1194update_cmd.parser.add_album_option()
1195update_cmd.parser.add_format_option()
1196update_cmd.parser.add_option(
1197    u'-m', u'--move', action='store_true', dest='move',
1198    help=u"move files in the library directory"
1199)
1200update_cmd.parser.add_option(
1201    u'-M', u'--nomove', action='store_false', dest='move',
1202    help=u"don't move files in library"
1203)
1204update_cmd.parser.add_option(
1205    u'-p', u'--pretend', action='store_true',
1206    help=u"show all changes but do nothing"
1207)
1208update_cmd.parser.add_option(
1209    u'-F', u'--field', default=None, action='append', dest='fields',
1210    help=u'list of fields to update'
1211)
1212update_cmd.func = update_func
1213default_commands.append(update_cmd)
1214
1215
1216# remove: Remove items from library, delete files.
1217
1218def remove_items(lib, query, album, delete, force):
1219    """Remove items matching query from lib. If album, then match and
1220    remove whole albums. If delete, also remove files from disk.
1221    """
1222    # Get the matching items.
1223    items, albums = _do_query(lib, query, album)
1224
1225    # Confirm file removal if not forcing removal.
1226    if not force:
1227        # Prepare confirmation with user.
1228        print_()
1229        if delete:
1230            fmt = u'$path - $title'
1231            prompt = u'Really DELETE %i file%s (y/n)?' % \
1232                     (len(items), 's' if len(items) > 1 else '')
1233        else:
1234            fmt = u''
1235            prompt = u'Really remove %i item%s from the library (y/n)?' % \
1236                     (len(items), 's' if len(items) > 1 else '')
1237
1238        # Show all the items.
1239        for item in items:
1240            ui.print_(format(item, fmt))
1241
1242        # Confirm with user.
1243        if not ui.input_yn(prompt, True):
1244            return
1245
1246    # Remove (and possibly delete) items.
1247    with lib.transaction():
1248        for obj in (albums if album else items):
1249            obj.remove(delete)
1250
1251
1252def remove_func(lib, opts, args):
1253    remove_items(lib, decargs(args), opts.album, opts.delete, opts.force)
1254
1255
1256remove_cmd = ui.Subcommand(
1257    u'remove', help=u'remove matching items from the library', aliases=(u'rm',)
1258)
1259remove_cmd.parser.add_option(
1260    u"-d", u"--delete", action="store_true",
1261    help=u"also remove files from disk"
1262)
1263remove_cmd.parser.add_option(
1264    u"-f", u"--force", action="store_true",
1265    help=u"do not ask when removing items"
1266)
1267remove_cmd.parser.add_album_option()
1268remove_cmd.func = remove_func
1269default_commands.append(remove_cmd)
1270
1271
1272# stats: Show library/query statistics.
1273
1274def show_stats(lib, query, exact):
1275    """Shows some statistics about the matched items."""
1276    items = lib.items(query)
1277
1278    total_size = 0
1279    total_time = 0.0
1280    total_items = 0
1281    artists = set()
1282    albums = set()
1283    album_artists = set()
1284
1285    for item in items:
1286        if exact:
1287            try:
1288                total_size += os.path.getsize(syspath(item.path))
1289            except OSError as exc:
1290                log.info(u'could not get size of {}: {}', item.path, exc)
1291        else:
1292            total_size += int(item.length * item.bitrate / 8)
1293        total_time += item.length
1294        total_items += 1
1295        artists.add(item.artist)
1296        album_artists.add(item.albumartist)
1297        if item.album_id:
1298            albums.add(item.album_id)
1299
1300    size_str = u'' + ui.human_bytes(total_size)
1301    if exact:
1302        size_str += u' ({0} bytes)'.format(total_size)
1303
1304    print_(u"""Tracks: {0}
1305Total time: {1}{2}
1306{3}: {4}
1307Artists: {5}
1308Albums: {6}
1309Album artists: {7}""".format(
1310        total_items,
1311        ui.human_seconds(total_time),
1312        u' ({0:.2f} seconds)'.format(total_time) if exact else '',
1313        u'Total size' if exact else u'Approximate total size',
1314        size_str,
1315        len(artists),
1316        len(albums),
1317        len(album_artists)),
1318    )
1319
1320
1321def stats_func(lib, opts, args):
1322    show_stats(lib, decargs(args), opts.exact)
1323
1324
1325stats_cmd = ui.Subcommand(
1326    u'stats', help=u'show statistics about the library or a query'
1327)
1328stats_cmd.parser.add_option(
1329    u'-e', u'--exact', action='store_true',
1330    help=u'exact size and time'
1331)
1332stats_cmd.func = stats_func
1333default_commands.append(stats_cmd)
1334
1335
1336# version: Show current beets version.
1337
1338def show_version(lib, opts, args):
1339    print_(u'beets version %s' % beets.__version__)
1340    print_(u'Python version {}'.format(python_version()))
1341    # Show plugins.
1342    names = sorted(p.name for p in plugins.find_plugins())
1343    if names:
1344        print_(u'plugins:', ', '.join(names))
1345    else:
1346        print_(u'no plugins loaded')
1347
1348
1349version_cmd = ui.Subcommand(
1350    u'version', help=u'output version information'
1351)
1352version_cmd.func = show_version
1353default_commands.append(version_cmd)
1354
1355
1356# modify: Declaratively change metadata.
1357
1358def modify_items(lib, mods, dels, query, write, move, album, confirm):
1359    """Modifies matching items according to user-specified assignments and
1360    deletions.
1361
1362    `mods` is a dictionary of field and value pairse indicating
1363    assignments. `dels` is a list of fields to be deleted.
1364    """
1365    # Parse key=value specifications into a dictionary.
1366    model_cls = library.Album if album else library.Item
1367
1368    for key, value in mods.items():
1369        mods[key] = model_cls._parse(key, value)
1370
1371    # Get the items to modify.
1372    items, albums = _do_query(lib, query, album, False)
1373    objs = albums if album else items
1374
1375    # Apply changes *temporarily*, preview them, and collect modified
1376    # objects.
1377    print_(u'Modifying {0} {1}s.'
1378           .format(len(objs), u'album' if album else u'item'))
1379    changed = []
1380    for obj in objs:
1381        if print_and_modify(obj, mods, dels) and obj not in changed:
1382            changed.append(obj)
1383
1384    # Still something to do?
1385    if not changed:
1386        print_(u'No changes to make.')
1387        return
1388
1389    # Confirm action.
1390    if confirm:
1391        if write and move:
1392            extra = u', move and write tags'
1393        elif write:
1394            extra = u' and write tags'
1395        elif move:
1396            extra = u' and move'
1397        else:
1398            extra = u''
1399
1400        changed = ui.input_select_objects(
1401            u'Really modify%s' % extra, changed,
1402            lambda o: print_and_modify(o, mods, dels)
1403        )
1404
1405    # Apply changes to database and files
1406    with lib.transaction():
1407        for obj in changed:
1408            obj.try_sync(write, move)
1409
1410
1411def print_and_modify(obj, mods, dels):
1412    """Print the modifications to an item and return a bool indicating
1413    whether any changes were made.
1414
1415    `mods` is a dictionary of fields and values to update on the object;
1416    `dels` is a sequence of fields to delete.
1417    """
1418    obj.update(mods)
1419    for field in dels:
1420        try:
1421            del obj[field]
1422        except KeyError:
1423            pass
1424    return ui.show_model_changes(obj)
1425
1426
1427def modify_parse_args(args):
1428    """Split the arguments for the modify subcommand into query parts,
1429    assignments (field=value), and deletions (field!).  Returns the result as
1430    a three-tuple in that order.
1431    """
1432    mods = {}
1433    dels = []
1434    query = []
1435    for arg in args:
1436        if arg.endswith('!') and '=' not in arg and ':' not in arg:
1437            dels.append(arg[:-1])  # Strip trailing !.
1438        elif '=' in arg and ':' not in arg.split('=', 1)[0]:
1439            key, val = arg.split('=', 1)
1440            mods[key] = val
1441        else:
1442            query.append(arg)
1443    return query, mods, dels
1444
1445
1446def modify_func(lib, opts, args):
1447    query, mods, dels = modify_parse_args(decargs(args))
1448    if not mods and not dels:
1449        raise ui.UserError(u'no modifications specified')
1450    modify_items(lib, mods, dels, query, ui.should_write(opts.write),
1451                 ui.should_move(opts.move), opts.album, not opts.yes)
1452
1453
1454modify_cmd = ui.Subcommand(
1455    u'modify', help=u'change metadata fields', aliases=(u'mod',)
1456)
1457modify_cmd.parser.add_option(
1458    u'-m', u'--move', action='store_true', dest='move',
1459    help=u"move files in the library directory"
1460)
1461modify_cmd.parser.add_option(
1462    u'-M', u'--nomove', action='store_false', dest='move',
1463    help=u"don't move files in library"
1464)
1465modify_cmd.parser.add_option(
1466    u'-w', u'--write', action='store_true', default=None,
1467    help=u"write new metadata to files' tags (default)"
1468)
1469modify_cmd.parser.add_option(
1470    u'-W', u'--nowrite', action='store_false', dest='write',
1471    help=u"don't write metadata (opposite of -w)"
1472)
1473modify_cmd.parser.add_album_option()
1474modify_cmd.parser.add_format_option(target='item')
1475modify_cmd.parser.add_option(
1476    u'-y', u'--yes', action='store_true',
1477    help=u'skip confirmation'
1478)
1479modify_cmd.func = modify_func
1480default_commands.append(modify_cmd)
1481
1482
1483# move: Move/copy files to the library or a new base directory.
1484
1485def move_items(lib, dest, query, copy, album, pretend, confirm=False,
1486               export=False):
1487    """Moves or copies items to a new base directory, given by dest. If
1488    dest is None, then the library's base directory is used, making the
1489    command "consolidate" files.
1490    """
1491    items, albums = _do_query(lib, query, album, False)
1492    objs = albums if album else items
1493    num_objs = len(objs)
1494
1495    # Filter out files that don't need to be moved.
1496    isitemmoved = lambda item: item.path != item.destination(basedir=dest)
1497    isalbummoved = lambda album: any(isitemmoved(i) for i in album.items())
1498    objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)]
1499    num_unmoved = num_objs - len(objs)
1500    # Report unmoved files that match the query.
1501    unmoved_msg = u''
1502    if num_unmoved > 0:
1503        unmoved_msg = u' ({} already in place)'.format(num_unmoved)
1504
1505    copy = copy or export  # Exporting always copies.
1506    action = u'Copying' if copy else u'Moving'
1507    act = u'copy' if copy else u'move'
1508    entity = u'album' if album else u'item'
1509    log.info(u'{0} {1} {2}{3}{4}.', action, len(objs), entity,
1510             u's' if len(objs) != 1 else u'', unmoved_msg)
1511    if not objs:
1512        return
1513
1514    if pretend:
1515        if album:
1516            show_path_changes([(item.path, item.destination(basedir=dest))
1517                               for obj in objs for item in obj.items()])
1518        else:
1519            show_path_changes([(obj.path, obj.destination(basedir=dest))
1520                               for obj in objs])
1521    else:
1522        if confirm:
1523            objs = ui.input_select_objects(
1524                u'Really %s' % act, objs,
1525                lambda o: show_path_changes(
1526                    [(o.path, o.destination(basedir=dest))]))
1527
1528        for obj in objs:
1529            log.debug(u'moving: {0}', util.displayable_path(obj.path))
1530
1531            if export:
1532                # Copy without affecting the database.
1533                obj.move(operation=MoveOperation.COPY, basedir=dest,
1534                         store=False)
1535            else:
1536                # Ordinary move/copy: store the new path.
1537                if copy:
1538                    obj.move(operation=MoveOperation.COPY, basedir=dest)
1539                else:
1540                    obj.move(operation=MoveOperation.MOVE, basedir=dest)
1541
1542
1543def move_func(lib, opts, args):
1544    dest = opts.dest
1545    if dest is not None:
1546        dest = normpath(dest)
1547        if not os.path.isdir(dest):
1548            raise ui.UserError(u'no such directory: %s' % dest)
1549
1550    move_items(lib, dest, decargs(args), opts.copy, opts.album, opts.pretend,
1551               opts.timid, opts.export)
1552
1553
1554move_cmd = ui.Subcommand(
1555    u'move', help=u'move or copy items', aliases=(u'mv',)
1556)
1557move_cmd.parser.add_option(
1558    u'-d', u'--dest', metavar='DIR', dest='dest',
1559    help=u'destination directory'
1560)
1561move_cmd.parser.add_option(
1562    u'-c', u'--copy', default=False, action='store_true',
1563    help=u'copy instead of moving'
1564)
1565move_cmd.parser.add_option(
1566    u'-p', u'--pretend', default=False, action='store_true',
1567    help=u'show how files would be moved, but don\'t touch anything'
1568)
1569move_cmd.parser.add_option(
1570    u'-t', u'--timid', dest='timid', action='store_true',
1571    help=u'always confirm all actions'
1572)
1573move_cmd.parser.add_option(
1574    u'-e', u'--export', default=False, action='store_true',
1575    help=u'copy without changing the database path'
1576)
1577move_cmd.parser.add_album_option()
1578move_cmd.func = move_func
1579default_commands.append(move_cmd)
1580
1581
1582# write: Write tags into files.
1583
1584def write_items(lib, query, pretend, force):
1585    """Write tag information from the database to the respective files
1586    in the filesystem.
1587    """
1588    items, albums = _do_query(lib, query, False, False)
1589
1590    for item in items:
1591        # Item deleted?
1592        if not os.path.exists(syspath(item.path)):
1593            log.info(u'missing file: {0}', util.displayable_path(item.path))
1594            continue
1595
1596        # Get an Item object reflecting the "clean" (on-disk) state.
1597        try:
1598            clean_item = library.Item.from_path(item.path)
1599        except library.ReadError as exc:
1600            log.error(u'error reading {0}: {1}',
1601                      displayable_path(item.path), exc)
1602            continue
1603
1604        # Check for and display changes.
1605        changed = ui.show_model_changes(item, clean_item,
1606                                        library.Item._media_tag_fields, force)
1607        if (changed or force) and not pretend:
1608            # We use `try_sync` here to keep the mtime up to date in the
1609            # database.
1610            item.try_sync(True, False)
1611
1612
1613def write_func(lib, opts, args):
1614    write_items(lib, decargs(args), opts.pretend, opts.force)
1615
1616
1617write_cmd = ui.Subcommand(u'write', help=u'write tag information to files')
1618write_cmd.parser.add_option(
1619    u'-p', u'--pretend', action='store_true',
1620    help=u"show all changes but do nothing"
1621)
1622write_cmd.parser.add_option(
1623    u'-f', u'--force', action='store_true',
1624    help=u"write tags even if the existing tags match the database"
1625)
1626write_cmd.func = write_func
1627default_commands.append(write_cmd)
1628
1629
1630# config: Show and edit user configuration.
1631
1632def config_func(lib, opts, args):
1633    # Make sure lazy configuration is loaded
1634    config.resolve()
1635
1636    # Print paths.
1637    if opts.paths:
1638        filenames = []
1639        for source in config.sources:
1640            if not opts.defaults and source.default:
1641                continue
1642            if source.filename:
1643                filenames.append(source.filename)
1644
1645        # In case the user config file does not exist, prepend it to the
1646        # list.
1647        user_path = config.user_config_path()
1648        if user_path not in filenames:
1649            filenames.insert(0, user_path)
1650
1651        for filename in filenames:
1652            print_(displayable_path(filename))
1653
1654    # Open in editor.
1655    elif opts.edit:
1656        config_edit()
1657
1658    # Dump configuration.
1659    else:
1660        config_out = config.dump(full=opts.defaults, redact=opts.redact)
1661        print_(util.text_string(config_out))
1662
1663
1664def config_edit():
1665    """Open a program to edit the user configuration.
1666    An empty config file is created if no existing config file exists.
1667    """
1668    path = config.user_config_path()
1669    editor = util.editor_command()
1670    try:
1671        if not os.path.isfile(path):
1672            open(path, 'w+').close()
1673        util.interactive_open([path], editor)
1674    except OSError as exc:
1675        message = u"Could not edit configuration: {0}".format(exc)
1676        if not editor:
1677            message += u". Please set the EDITOR environment variable"
1678        raise ui.UserError(message)
1679
1680config_cmd = ui.Subcommand(u'config',
1681                           help=u'show or edit the user configuration')
1682config_cmd.parser.add_option(
1683    u'-p', u'--paths', action='store_true',
1684    help=u'show files that configuration was loaded from'
1685)
1686config_cmd.parser.add_option(
1687    u'-e', u'--edit', action='store_true',
1688    help=u'edit user configuration with $EDITOR'
1689)
1690config_cmd.parser.add_option(
1691    u'-d', u'--defaults', action='store_true',
1692    help=u'include the default configuration'
1693)
1694config_cmd.parser.add_option(
1695    u'-c', u'--clear', action='store_false',
1696    dest='redact', default=True,
1697    help=u'do not redact sensitive fields'
1698)
1699config_cmd.func = config_func
1700default_commands.append(config_cmd)
1701
1702
1703# completion: print completion script
1704
1705def print_completion(*args):
1706    for line in completion_script(default_commands + plugins.commands()):
1707        print_(line, end=u'')
1708    if not any(map(os.path.isfile, BASH_COMPLETION_PATHS)):
1709        log.warning(u'Warning: Unable to find the bash-completion package. '
1710                    u'Command line completion might not work.')
1711
1712BASH_COMPLETION_PATHS = map(syspath, [
1713    u'/etc/bash_completion',
1714    u'/usr/share/bash-completion/bash_completion',
1715    u'/usr/local/share/bash-completion/bash_completion',
1716    # SmartOS
1717    u'/opt/local/share/bash-completion/bash_completion',
1718    # Homebrew (before bash-completion2)
1719    u'/usr/local/etc/bash_completion',
1720])
1721
1722
1723def completion_script(commands):
1724    """Yield the full completion shell script as strings.
1725
1726    ``commands`` is alist of ``ui.Subcommand`` instances to generate
1727    completion data for.
1728    """
1729    base_script = os.path.join(_package_path('beets.ui'), 'completion_base.sh')
1730    with open(base_script, 'r') as base_script:
1731        yield util.text_string(base_script.read())
1732
1733    options = {}
1734    aliases = {}
1735    command_names = []
1736
1737    # Collect subcommands
1738    for cmd in commands:
1739        name = cmd.name
1740        command_names.append(name)
1741
1742        for alias in cmd.aliases:
1743            if re.match(r'^\w+$', alias):
1744                aliases[alias] = name
1745
1746        options[name] = {u'flags': [], u'opts': []}
1747        for opts in cmd.parser._get_all_options()[1:]:
1748            if opts.action in ('store_true', 'store_false'):
1749                option_type = u'flags'
1750            else:
1751                option_type = u'opts'
1752
1753            options[name][option_type].extend(
1754                opts._short_opts + opts._long_opts
1755            )
1756
1757    # Add global options
1758    options['_global'] = {
1759        u'flags': [u'-v', u'--verbose'],
1760        u'opts':
1761            u'-l --library -c --config -d --directory -h --help'.split(u' ')
1762    }
1763
1764    # Add flags common to all commands
1765    options['_common'] = {
1766        u'flags': [u'-h', u'--help']
1767    }
1768
1769    # Start generating the script
1770    yield u"_beet() {\n"
1771
1772    # Command names
1773    yield u"  local commands='%s'\n" % ' '.join(command_names)
1774    yield u"\n"
1775
1776    # Command aliases
1777    yield u"  local aliases='%s'\n" % ' '.join(aliases.keys())
1778    for alias, cmd in aliases.items():
1779        yield u"  local alias__%s=%s\n" % (alias.replace('-', '_'), cmd)
1780    yield u'\n'
1781
1782    # Fields
1783    yield u"  fields='%s'\n" % ' '.join(
1784        set(
1785            list(library.Item._fields.keys()) +
1786            list(library.Album._fields.keys())
1787        )
1788    )
1789
1790    # Command options
1791    for cmd, opts in options.items():
1792        for option_type, option_list in opts.items():
1793            if option_list:
1794                option_list = u' '.join(option_list)
1795                yield u"  local %s__%s='%s'\n" % (
1796                    option_type, cmd.replace('-', '_'), option_list)
1797
1798    yield u'  _beet_dispatch\n'
1799    yield u'}\n'
1800
1801
1802completion_cmd = ui.Subcommand(
1803    'completion',
1804    help=u'print shell script that provides command line completion'
1805)
1806completion_cmd.func = print_completion
1807completion_cmd.hide = True
1808default_commands.append(completion_cmd)
1809