# This file is part of beets. # Copyright 2015 # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Open metadata information in a text editor to let the user edit it. """ from __future__ import (division, absolute_import, print_function, unicode_literals) from beets import plugins from beets import util from beets import library from beets import ui from beets.ui.commands import _do_query import subprocess import yaml import collections from sys import exit from tempfile import NamedTemporaryFile import os def edit(filename): """Open `filename` in a test editor. """ cmd = util.shlex_split(util.editor_command()) cmd.append(filename) subprocess.call(cmd) class EditPlugin(plugins.BeetsPlugin): def __init__(self): super(EditPlugin, self).__init__() self.config.add({ 'editor': '', 'albumfields': 'album albumartist', 'itemfields': 'track title artist album', 'not_fields': 'id path', }) # The editor field in the config lets you specify your editor. self.editor = self.config['editor'].as_str_seq() # the albumfields field in your config sets the tags that # you want to see/change for albums. # Defaults to album albumartist. # the ID tag will always be listed as it is used to identify the item self.albumfields = self.config['albumfields'].as_str_seq() # the itemfields field in your config sets the tags that # you want to see/change or items. # Defaults to track title artist album. # the ID tag will always be listed as it is used to identify the item self.itemfields = self.config['itemfields'].as_str_seq() # the not_fields field in your config sets the tags that # will not be changed. # If you happen to change them, they will be restored to the original # value. The ID of an item will never be changed. self.not_fields = self.config['not_fields'].as_str_seq() self.ed = None self.ed_args = None def commands(self): edit_command = ui.Subcommand( 'edit', help='interactively edit metadata' ) edit_command.parser.add_option( '-e', '--extra', action='append', type='choice', choices=library.Item.all_keys() + library.Album.all_keys(), help='add additional fields to edit', ) edit_command.parser.add_option( '--all', action='store_true', dest='all', help='edit all fields', ) edit_command.parser.add_all_common_options() edit_command.func = self.editor_music return [edit_command] def editor_music(self, lib, opts, args): if self.editor: self.ed_args = self.editor[1:] if len(self.editor) > 1 else None self.ed = self.editor[0] if self.editor else None # main program flow # Get the objects to edit. query = ui.decargs(args) items, albums = _do_query(lib, query, opts.album, False) objs = albums if opts.album else items if not objs: ui.print_('Nothing to edit.') return # Confirmation from user about the queryresult for obj in objs: ui.print_(format(obj)) if not ui.input_yn(ui.colorize('action_default', "Edit? (y/n)"), True): return # get the fields from the objects if opts.all: data = self.get_all_fields(objs) else: fields = self.get_fields_from(objs, opts) data = self.get_selected_fields(fields, objs, opts) # present the yaml to the user and let her change it newyaml, oldyaml = self.change_objs(data) changed_objs = self.check_diff(newyaml, oldyaml, opts) if not changed_objs: ui.print_("nothing to change") return self.save_items(changed_objs, lib, opts) def print_to_yaml(self, arg): # from object to yaml return yaml.safe_dump_all( arg, allow_unicode=True, default_flow_style=False) def yaml_to_dict(self, yam): # from yaml to object return yaml.load_all(yam) def get_fields_from(self, objs, opts): # construct a list of fields we need # see if we need album or item fields fields = self.albumfields if opts.album else self.itemfields # if opts.extra is given add those if opts.extra: fields.extend([f for f in opts.extra if f not in fields]) # make sure we got the id for identification if 'id' not in fields: fields.insert(0, 'id') # we need all the fields if opts.all: fields = None ui.print_(ui.colorize('text_warning', "edit all fields from:")) else: for it in fields: if opts.album: # check if it is really an albumfield if it not in library.Album.all_keys(): ui.print_( "{} not in albumfields.Removed it.".format( ui.colorize( 'text_warning', it))) fields.remove(it) else: # if it is not an itemfield remove it if it not in library.Item.all_keys(): ui.print_( "{} not in itemfields.Removed it.".format( ui.colorize( 'text_warning', it))) fields.remove(it) return fields def get_selected_fields(self, myfields, objs, opts): return [[{field: obj[field]}for field in myfields]for obj in objs] def get_all_fields(self, objs): return [[{field: obj[field]}for field in sorted(obj._fields)] for obj in objs] def change_objs(self, dict_items): # construct a yaml from the original object-fields # and make a yaml that we can change in the text-editor oldyaml = self.print_to_yaml(dict_items) # our backup newyaml = self.print_to_yaml(dict_items) # goes to user new = NamedTemporaryFile(suffix='.yaml', delete=False) new.write(newyaml) new.close() edit(new.name) # wait for user to edit yaml and continue if ui.input_yn(ui.colorize('action_default', "done?(y)"), True): while True: try: # reading the yaml back in with open(new.name) as f: newyaml = f.read() list(yaml.load_all(newyaml)) os.remove(new.name) break except yaml.YAMLError as e: # some error-correcting mainly for empty-values # not being well-formated ui.print_(ui.colorize('text_warning', "change this fault: {}".format(e))) ui.print_("correct format for empty = - '' :") if ui.input_yn( ui.colorize('action_default', "fix?(y)"), True): edit(new.name) if ui.input_yn(ui.colorize( 'action_default', "ok.fixed.(y)"), True): pass # only continue when all the mistakes are corrected return newyaml, oldyaml else: os.remove(new.name) exit() def nice_format(self, newset): # format the results so that we have an ID at the top # that we can change to a userfrienly title/artist format # when we present our results wellformed = collections.defaultdict(dict) for item in newset: for field in item: wellformed[item[0].values()[0]].update(field) return wellformed def save_items(self, oldnewlist, lib, opts): oldset, newset = zip(*oldnewlist) niceNewSet = self.nice_format(newset) niceOldSet = self.nice_format(oldset) niceCombiSet = zip(niceOldSet.items(), niceNewSet.items()) newSetTitled = [] oldSetTitled = [] changedObjs = [] for o, n in niceCombiSet: if opts.album: ob = lib.get_album(int(n[0])) else: ob = lib.get_item(n[0]) # change id to item-string oldSetTitled.append((format(ob),) + o[1:]) ob.update(n[1]) # update the object newSetTitled.append((format(ob),) + n[1:]) changedObjs.append(ob) # see the changes we made for obj in changedObjs: ui.show_model_changes(obj) self.save_write(changedObjs) def save_write(self, changedob): if not ui.input_yn( ui.colorize('action_default', 'really modify? (y/n)')): return for ob in changedob: self._log.debug('saving changes to {}', ob) ob.try_sync(ui.should_write()) return def check_diff(self, newyaml, oldyaml, opts): # make python objs from yamlstrings nl = self.yaml_to_dict(newyaml) ol = self.yaml_to_dict(oldyaml) return filter(None, map(self.reduce_it, ol, nl)) def reduce_it(self, ol, nl): # if there is a forbidden field it resets them if ol != nl: for x in range(0, len(nl)): if ol[x] != nl[x] and ol[x].keys()[0]in self.not_fields: nl[x] = ol[x] ui.print_("reset forbidden field.") if ol != nl: # only keep objects that have changed return ol, nl