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