From fe012eae774c97699fe89ae6bd8a9022f8b09e05 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Mon, 2 Nov 2015 21:13:58 +0100 Subject: [PATCH 01/93] new plugin yamleditor --- beetsplug/yamleditor.py | 329 ++++++++++++++++++++++++++++++++++++ docs/plugins/yamleditor.rst | 79 +++++++++ 2 files changed, 408 insertions(+) create mode 100644 beetsplug/yamleditor.py create mode 100644 docs/plugins/yamleditor.rst diff --git a/beetsplug/yamleditor.py b/beetsplug/yamleditor.py new file mode 100644 index 000000000..963be9829 --- /dev/null +++ b/beetsplug/yamleditor.py @@ -0,0 +1,329 @@ +# 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 tags of items in texteditor,change them and save back to the items. +""" +from __future__ import (division, absolute_import, print_function, + unicode_literals) + +from beets import plugins +from beets.ui import Subcommand, decargs, library, print_ +import subprocess +import difflib +import yaml +import collections +import webbrowser +from sys import exit +from beets import config +from beets import ui +from tempfile import NamedTemporaryFile + + +class yamleditorPlugin(plugins.BeetsPlugin): + + def __init__(self): + super(yamleditorPlugin, self).__init__() + + self.config.add({ + 'style': 'yaml', + 'editor': '', + 'diff_method': '', + 'html_viewer': '', + 'editor_args': '', + 'html_args': '', + 'albumfields': 'album albumartist', + 'itemfields': 'track title artist album ', + 'not_fields': 'path', + 'separator': '-' + + }) + + def commands(self): + yamleditor_command = Subcommand( + 'yamleditor', + help='send items to yamleditor for editing tags') + yamleditor_command.parser.add_option( + '-e', '--extra', + action='store', + help='add additional fields to edit', + ) + yamleditor_command.parser.add_all_common_options() + yamleditor_command.func = self.editor_music + return[yamleditor_command] + + def editor_music(self, lib, opts, args): + """edit tags in a textfile in yaml-style + """ + self.style = self.config['style'].get() + """the editor field in the config lets you specify your editor. + Defaults to open with webrowser module""" + self.editor = self.config['editor'].get() + """the editor_args field in your config lets you specify + additional args for your editor""" + self.editor_args = self.config['editor_args'].get().split() + """the html_viewer field in your config lets you specify + your htmlviewer. Defaults to open with webrowser module""" + self.html_viewer = self.config['html_viewer'].get() + """the html_args field in your config lets you specify + additional args for your viewer""" + self.html_args = self.config['html_args'].get().split() + """the diff_method field in your config picks the way to see your + changes. Options are: + 'ndiff'(2 files with differences), + 'unified'(just the different lines and a few lines of context), + 'html'(view in html-format), + 'vimdiff'(view in VIM)""" + self.diff_method = self.config['diff_method'].get() + """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'].get().split() + """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'].get().split() + '''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'].get().split() + '''the separator in your config sets the separator that will be used + between fields in your terminal. Defaults to -''' + self.separator = self.config['separator'].get() + + query = decargs(args) + self.print_items = { + 'yaml': self.print_to_yaml} + self.diffresults = { + 'ndiff': self.ndiff, + 'unified': self.unified, + 'html': self.html, + 'vimdiff': self.vimdiff} + self.make_dict = { + 'all': self.get_all_fields, + "selected": self.get_selected_fields} + self.string_to_dict = { + 'yaml': self.yaml_to_dict} + + objs = self._get_objs(lib, opts, query) + if not objs: + print_('nothing found') + return + fmt = self.get_fields_from(objs, opts) + print_(fmt) + [print_(format(item, fmt)) for item in objs] + if not ui.input_yn(ui.colorize('action_default', "Edit?(n/y)"), True): + return + dict_from_objs = self.make_dict[self.pick](self.fields, objs, opts) + newyaml, oldyaml = self.change_objs(dict_from_objs) + changed_objs = self.check_diff(newyaml, oldyaml) + if not changed_objs: + print_("nothing to change") + return + self.save_items(changed_objs, lib, fmt, opts) + + '''from object to yaml''' + def print_to_yaml(self, arg): + return yaml.safe_dump_all( + arg, + allow_unicode=True, + default_flow_style=False) + + '''from yaml to object''' + def yaml_to_dict(self, yam): + return yaml.load_all(yam) + + def _get_objs(self, lib, opts, query): + if opts.album: + return list(lib.albums(query)) + else: + return list(lib.items(query)) + + def get_fields_from(self, objs, opts): + cl = ui.colorize('action', self.separator) + self.fields = self.albumfields if opts.album else self.itemfields + if opts.format: + self.fields = [] + self.fields.extend((opts.format).replace('$', "").split()) + if opts.extra: + fi = (opts.extra).replace('$', "").split() + self.fields.extend([f for f in fi if f not in self.fields]) + if 'id' not in self.fields: + self.fields.insert(0, 'id') + if "_all" in self.fields: + self.fields = None + self.pick = "all" + print_(ui.colorize('text_warning', "edit all fields from ...")) + if opts.album: + fmt = cl + cl.join(['$albumartist', '$album']) + else: + fmt = cl + cl.join(['$title', '$artist']) + else: + for it in self.fields: + if opts.album: + if it not in library.Album.all_keys(): + print_( + "{} not in albumfields.Removed it.".format( + ui.colorize( + 'text_warning', it))) + self.fields.remove(it) + else: + if it not in library.Item.all_keys(): + print_( + "{} not in itemfields.Removed it.".format( + ui.colorize( + 'text_warning', it))) + self.fields.remove(it) + self.pick = "selected" + fmtfields = ["$" + it for it in self.fields] + fmt = cl + cl.join(fmtfields[1:]) + + return fmt + + '''get the fields we want and make a dic from them''' + def get_selected_fields(self, myfields, objs, opts): + a = [] + for mod in objs: + a.append([{fi: mod[fi]}for fi in myfields]) + return a + + def get_all_fields(self, myfields, objs, opts): + a = [] + for mod in objs: + a.append([{fi: mod[fi]} for fi in sorted(mod._fields)]) + return a + + def change_objs(self, dict_items): + oldyaml = self.print_items[self.style](dict_items) + newyaml = self.print_items[self.style](dict_items) + new = NamedTemporaryFile(suffix='.yaml', delete=False) + new.write(newyaml) + new.close() + if not self.editor: + webbrowser.open(new.name, new=2, autoraise=True) + if self.editor and not self.editor_args: + subprocess.check_call([self.editor, new.name]) + elif self.editor and self.editor_args: + subprocess.check_call( + [self.editor, new.name, self.editor_args]) + + if ui.input_yn(ui.colorize('action_default', "done?(y)"), True): + with open(new.name) as f: + newyaml = f.read() + return newyaml, oldyaml + else: + exit() + + def save_items(self, oldnewlist, lib, fmt, opts): + oldset = [] + newset = [] + for old, new in oldnewlist: + oldset.append(old) + newset.append(new) + + no = [] + for newitem in range(0, len(newset)): + ordict = collections.OrderedDict() + for each in newset[newitem]: + ordict.update(each) + no.append(ordict) + + changedob = [] + for each in no: + if not opts.album: + ob = lib.get_item(each['id']) + else: + ob = lib.get_album(each['id']) + ob.update(each) + changedob.append(ob) + + if self.diff_method: + ostr = self.print_items[self.style](oldset) + nwstr = self.print_items[self.style](newset) + self.diffresults[self.diff_method](ostr, nwstr) + else: + for obj in changedob: + ui.show_model_changes(obj) + self.save_write(changedob) + + def save_write(self, changedob): + if not ui.input_yn('really modify? (y/n)'): + return + + for ob in changedob: + if config['import']['write'].get(bool): + ob.try_sync() + else: + ob.store() + print("changed: {0}".format(ob)) + + return + + def check_diff(self, newyaml, oldyaml): + nl = self.string_to_dict[self.style](newyaml) + ol = self.string_to_dict[self.style](oldyaml) + return filter(None, map(self.reduce_it, ol, nl)) + + '''if there is a forbidden field it gathers them here(check_ids)''' + def reduce_it(self, ol, nl): + 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] + print_("reset forbidden field.") + if ol != nl: + return ol, nl + + def ndiff(self, newfilestr, oldfilestr): + newlines = newfilestr.splitlines() + oldlines = oldfilestr.splitlines() + diff = difflib.ndiff(newlines, oldlines) + print_('\n'.join(list(diff))) + return + + def unified(self, newfilestr, oldfilestr): + newlines = newfilestr.splitlines() + oldlines = oldfilestr.splitlines() + diff = difflib.unified_diff(newlines, oldlines, lineterm='') + print_('\n'.join(list(diff))) + return + + def html(self, newfilestr, oldfilestr): + newlines = newfilestr.splitlines() + oldlines = oldfilestr.splitlines() + diff = difflib.HtmlDiff() + df = diff.make_file(newlines, oldlines) + ht = NamedTemporaryFile('w', suffix='.html', delete=False) + ht.write(df) + hdn = ht.name + if not self.html_viewer: + webbrowser.open(hdn, new=2, autoraise=True) + else: + callmethod = [self.html_viewer] + callmethod.extend(self.html_args) + callmethod.append(hdn) + subprocess.call(callmethod) + return + + def vimdiff(self, newstringstr, oldstringstr): + + newdiff = NamedTemporaryFile(suffix='.old.yaml', delete=False) + newdiff.write(newstringstr) + newdiff.close() + olddiff = NamedTemporaryFile(suffix='.new.yaml', delete=False) + olddiff.write(oldstringstr) + olddiff.close() + subprocess.call(['vimdiff', newdiff.name, olddiff.name]) diff --git a/docs/plugins/yamleditor.rst b/docs/plugins/yamleditor.rst new file mode 100644 index 000000000..a81a58827 --- /dev/null +++ b/docs/plugins/yamleditor.rst @@ -0,0 +1,79 @@ +Yamleditor Plugin +================ +The ``yamleditor`` plugin lets you open the tags, fields from a group of items +, edit them in a text-editor and save them back. + +You simply put in a query like you normally do in beets. + + beet yamleditor beatles + beet yamleditor beatles -a + beet yamleditor beatles -f'$title-$lyrics' + + +You get a list of hits and then you can edit them. +The ``yamleditor`` opens your standard text-editor with a list of your hits +and for each hit a bunch of fields. + +Without anything specified in your ``config.yaml`` for ``yamleditor:`` +you will get + + track-$title-$artist-$album for items +and + + $album-$albumartist for albums + +you can get more fields from the cmdline by adding + + -f '$genre $added' +or + + -e '$year $comments' + +If you use ``-f '$field $field'`` you get *only* what you specified. + +If you use ``-e '$field $field'`` you get what you specified *extra*. + + -f or -e '$_all' gets you all the fields + +After you edit the values in your text-editor - *and you may only edit the values, +no deleting fields or adding fields!* - you save the file, answer with y on ``Done`` and +you get a summary of your changes. +Check em, answer y or n and the changes are written to your library. + +Configuration +------------ + +Make a ``yamleditor:`` section in your config.yaml ``(beet config -e)`` + + yamleditor: + editor: # specify the editor you like + editor_args: # additional arguments for editor + diff_method: ndiff # 4 different ways to view your changes + diff_method: unified # Pick one. See ex https://pymotw.com/2/difflib/ + diff_method: html # for details or just try it :) + diff_method: vimdiff # this opens up vim diff + html_viewer: # viewer to see the diff_method html + html_args : # additional arguments for the viewer + albumfields: genre album # a list of albumfields to edit + itemfields: track artist # a list of itemfields to edit + not_fields: id path # list of not-wanted fields + separator: "|>" # something that separates printed fields + +* You can pick 1 of the diff-methods (or none and you get the beet way). +If you pick ``html`` you can specify a viewer for it. If not the systems-default +will be picked. + +* The ``albumfields`` and ``itemfields`` let you put in a list of fields you want to change. +``albumfields`` gets picked if you put -a in your search query else ``itemfields``. For a list of fields +do the ``beet fields``. + +* The ``not_fields`` always contain ``id`` and standard also the ``path``. +Don't want to mess with them. + +* The default ``separator`` prints like: + + -02-The Night Before-The Beatles-Help! + + but with ex "|>" it will look like: + + |>02|>The Night Before|>The Beatles|>Help! From 6c08aae3a4b02f6350e7af873aa021a9098bb21e Mon Sep 17 00:00:00 2001 From: jmwatte Date: Mon, 2 Nov 2015 21:42:58 +0100 Subject: [PATCH 02/93] Update changelog.rst added yamleditor(a new plugin) entry --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 070dfeaf7..72f37bbf7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,6 +3,10 @@ Changelog 1.3.16 (in development) ----------------------- +* A new :doc:`/plugins/yamleditor` helps you manually edit fields from items. + You search for items in the normal beets way.Then yamleditor opens a texteditor + with the items and the fields of the items you want to edit. Afterwards you can + review your changes save them back into the items. Fixes: From d5c9be44d466026af5a960dfd90887431700d49f Mon Sep 17 00:00:00 2001 From: jmwatte Date: Mon, 2 Nov 2015 22:02:20 +0100 Subject: [PATCH 03/93] Update changelog.rst --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 72f37bbf7..8182bdd69 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,7 +3,7 @@ Changelog 1.3.16 (in development) ----------------------- -* A new :doc:`/plugins/yamleditor` helps you manually edit fields from items. +* A new plugin yamleditor helps you manually edit fields from items. You search for items in the normal beets way.Then yamleditor opens a texteditor with the items and the fields of the items you want to edit. Afterwards you can review your changes save them back into the items. From e3e81502779e7a1cdee5bfd20221bf40494a21f0 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Mon, 2 Nov 2015 22:07:59 +0100 Subject: [PATCH 04/93] Update yamleditor.rst polishing --- docs/plugins/yamleditor.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins/yamleditor.rst b/docs/plugins/yamleditor.rst index a81a58827..2c51cb2ec 100644 --- a/docs/plugins/yamleditor.rst +++ b/docs/plugins/yamleditor.rst @@ -1,5 +1,5 @@ Yamleditor Plugin -================ +================= The ``yamleditor`` plugin lets you open the tags, fields from a group of items , edit them in a text-editor and save them back. @@ -41,7 +41,7 @@ you get a summary of your changes. Check em, answer y or n and the changes are written to your library. Configuration ------------- +------------- Make a ``yamleditor:`` section in your config.yaml ``(beet config -e)`` From a3ee1d0d3795235a33c31d65cee9cef1889889aa Mon Sep 17 00:00:00 2001 From: jmwatte Date: Mon, 2 Nov 2015 22:21:03 +0100 Subject: [PATCH 05/93] Update yamleditor.rst more polishing --- docs/plugins/yamleditor.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plugins/yamleditor.rst b/docs/plugins/yamleditor.rst index 2c51cb2ec..fbb9f4eda 100644 --- a/docs/plugins/yamleditor.rst +++ b/docs/plugins/yamleditor.rst @@ -18,6 +18,7 @@ Without anything specified in your ``config.yaml`` for ``yamleditor:`` you will get track-$title-$artist-$album for items + and $album-$albumartist for albums @@ -25,6 +26,7 @@ and you can get more fields from the cmdline by adding -f '$genre $added' + or -e '$year $comments' From 151c1f3fbd591900f92c807baf0f19b87e35ad5a Mon Sep 17 00:00:00 2001 From: jmwatte Date: Mon, 2 Nov 2015 23:00:02 +0100 Subject: [PATCH 06/93] Update yamleditor.rst polishing --- docs/plugins/yamleditor.rst | 70 +++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/docs/plugins/yamleditor.rst b/docs/plugins/yamleditor.rst index fbb9f4eda..7317c3411 100644 --- a/docs/plugins/yamleditor.rst +++ b/docs/plugins/yamleditor.rst @@ -5,9 +5,12 @@ The ``yamleditor`` plugin lets you open the tags, fields from a group of items You simply put in a query like you normally do in beets. - beet yamleditor beatles - beet yamleditor beatles -a - beet yamleditor beatles -f'$title-$lyrics' + `beet yamleditor beatles` + + `beet yamleditor beatles -a` + + `beet yamleditor beatles -f'$title-$lyrics'` + You get a list of hits and then you can edit them. @@ -17,25 +20,25 @@ and for each hit a bunch of fields. Without anything specified in your ``config.yaml`` for ``yamleditor:`` you will get - track-$title-$artist-$album for items + `track-$title-$artist-$album` for items and - $album-$albumartist for albums + `$album-$albumartist` for albums you can get more fields from the cmdline by adding - -f '$genre $added' + `-f '$genre $added'` or - -e '$year $comments' + `-e '$year $comments'` If you use ``-f '$field $field'`` you get *only* what you specified. If you use ``-e '$field $field'`` you get what you specified *extra*. - -f or -e '$_all' gets you all the fields + ``-f or -e '$_all'`` gets you all the fields After you edit the values in your text-editor - *and you may only edit the values, no deleting fields or adding fields!* - you save the file, answer with y on ``Done`` and @@ -48,34 +51,39 @@ Configuration Make a ``yamleditor:`` section in your config.yaml ``(beet config -e)`` yamleditor: - editor: # specify the editor you like - editor_args: # additional arguments for editor - diff_method: ndiff # 4 different ways to view your changes - diff_method: unified # Pick one. See ex https://pymotw.com/2/difflib/ - diff_method: html # for details or just try it :) - diff_method: vimdiff # this opens up vim diff - html_viewer: # viewer to see the diff_method html - html_args : # additional arguments for the viewer - albumfields: genre album # a list of albumfields to edit - itemfields: track artist # a list of itemfields to edit - not_fields: id path # list of not-wanted fields - separator: "|>" # something that separates printed fields - -* You can pick 1 of the diff-methods (or none and you get the beet way). -If you pick ``html`` you can specify a viewer for it. If not the systems-default -will be picked. - + * editor: nano + * editor_args: + * diff_method: ndiff + * html_viewer:firefox + * html_args : + * albumfields: genre album + * itemfields: track artist + * not_fields: id path + * separator: "|>" + +* editor: you can pick your own texteditor. Defaults to systems default. +* editor_args: in case you need extra arguments for your text-editor. +* diff_method: 4 choices + * ndiff: you see original and the changed yaml files with the changes + * unified: you see the changes with a bit of context. Simple and compact. + * html: a html file that you can open in a browser. Looks nice. + * vimdiff: gives you VIM with the diffs + with no diff_method you get the beets way of showing differences. +* html_viewer: + If you pick ``html`` you can specify a viewer for it. If not the systems-default + will be picked. +* html_args: in case your html_viewer needs arguments * The ``albumfields`` and ``itemfields`` let you put in a list of fields you want to change. -``albumfields`` gets picked if you put -a in your search query else ``itemfields``. For a list of fields -do the ``beet fields``. + ``albumfields`` gets picked if you put -a in your search query else ``itemfields``. For a list of fields + do the ``beet fields``. * The ``not_fields`` always contain ``id`` and standard also the ``path``. -Don't want to mess with them. + Don't want to mess with them. * The default ``separator`` prints like: - -02-The Night Before-The Beatles-Help! + ``-02-The Night Before-The Beatles-Help!`` - but with ex "|>" it will look like: + but with ex "|>" it will look like: - |>02|>The Night Before|>The Beatles|>Help! + ``|>02|>The Night Before|>The Beatles|>Help!`` From 149dd8676a351d9f8437a0845c677478859f5065 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Mon, 2 Nov 2015 23:09:50 +0100 Subject: [PATCH 07/93] Update yamleditor.rst polishing --- docs/plugins/yamleditor.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plugins/yamleditor.rst b/docs/plugins/yamleditor.rst index 7317c3411..21321ca08 100644 --- a/docs/plugins/yamleditor.rst +++ b/docs/plugins/yamleditor.rst @@ -59,7 +59,7 @@ Make a ``yamleditor:`` section in your config.yaml ``(beet config -e)`` * albumfields: genre album * itemfields: track artist * not_fields: id path - * separator: "|>" + * separator: "<>" * editor: you can pick your own texteditor. Defaults to systems default. * editor_args: in case you need extra arguments for your text-editor. @@ -84,6 +84,6 @@ Make a ``yamleditor:`` section in your config.yaml ``(beet config -e)`` ``-02-The Night Before-The Beatles-Help!`` - but with ex "|>" it will look like: + but with ex "<>" it will look like: - ``|>02|>The Night Before|>The Beatles|>Help!`` + ``<>02<>The Night Before<>The Beatles<>Help!`` From fdbf9e59e7d11b2462646ff12f81ff31f6520bf6 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Mon, 2 Nov 2015 23:11:42 +0100 Subject: [PATCH 08/93] Update yamleditor.py polishing --- beetsplug/yamleditor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/yamleditor.py b/beetsplug/yamleditor.py index 963be9829..1124edd0e 100644 --- a/beetsplug/yamleditor.py +++ b/beetsplug/yamleditor.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2015 +# Copyright 2015, Jean-Marie Winters # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the From 3741e28947f0edf2080d6a3ba662e21733b1e68c Mon Sep 17 00:00:00 2001 From: jmwatte Date: Mon, 2 Nov 2015 23:23:57 +0100 Subject: [PATCH 09/93] Update yamleditor.rst polishing --- docs/plugins/yamleditor.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins/yamleditor.rst b/docs/plugins/yamleditor.rst index 21321ca08..863a34bfb 100644 --- a/docs/plugins/yamleditor.rst +++ b/docs/plugins/yamleditor.rst @@ -63,12 +63,12 @@ Make a ``yamleditor:`` section in your config.yaml ``(beet config -e)`` * editor: you can pick your own texteditor. Defaults to systems default. * editor_args: in case you need extra arguments for your text-editor. -* diff_method: 4 choices +* diff_method: 4 choices with no diff_method you get the beets way of showing differences. * ndiff: you see original and the changed yaml files with the changes * unified: you see the changes with a bit of context. Simple and compact. * html: a html file that you can open in a browser. Looks nice. * vimdiff: gives you VIM with the diffs - with no diff_method you get the beets way of showing differences. + * html_viewer: If you pick ``html`` you can specify a viewer for it. If not the systems-default will be picked. From f0040370ec47bc7295ecccde0eae9df45beb4377 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Mon, 2 Nov 2015 23:57:11 +0100 Subject: [PATCH 10/93] Update yamleditor.rst polishing --- docs/plugins/yamleditor.rst | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/docs/plugins/yamleditor.rst b/docs/plugins/yamleditor.rst index 863a34bfb..d5f3641ba 100644 --- a/docs/plugins/yamleditor.rst +++ b/docs/plugins/yamleditor.rst @@ -1,7 +1,6 @@ Yamleditor Plugin ================= -The ``yamleditor`` plugin lets you open the tags, fields from a group of items -, edit them in a text-editor and save them back. +The ``yamleditor`` plugin lets you open the tags, fields from a group of items, edit them in a text-editor and save them back. You simply put in a query like you normally do in beets. @@ -13,20 +12,19 @@ You simply put in a query like you normally do in beets. -You get a list of hits and then you can edit them. -The ``yamleditor`` opens your standard text-editor with a list of your hits -and for each hit a bunch of fields. +You get a list of hits and then you can edit them. The ``yamleditor`` opens your standard text-editor with a list of your hits and for each hit a bunch of fields. -Without anything specified in your ``config.yaml`` for ``yamleditor:`` -you will get +Without anything specified in your ``config.yaml`` for ``yamleditor:`` you will get - `track-$title-$artist-$album` for items +for items + + `track-$title-$artist-$album` -and +and for albums - `$album-$albumartist` for albums + `$album-$albumartist` -you can get more fields from the cmdline by adding +you can get fields from the cmdline by adding `-f '$genre $added'` @@ -34,16 +32,13 @@ or `-e '$year $comments'` -If you use ``-f '$field $field'`` you get *only* what you specified. +If you use ``-f '$field ...'`` you get *only* what you specified. -If you use ``-e '$field $field'`` you get what you specified *extra*. +If you use ``-e '$field ...'`` you get what you specified *extra*. - ``-f or -e '$_all'`` gets you all the fields +If you use ``-f or -e '$_all'`` you get all the fields. -After you edit the values in your text-editor - *and you may only edit the values, -no deleting fields or adding fields!* - you save the file, answer with y on ``Done`` and -you get a summary of your changes. -Check em, answer y or n and the changes are written to your library. +After you edit the values in your text-editor - *and you may only edit the values, no deleting fields or adding fields!* - you save the file, answer with y on ``Done`` and you get a summary of your changes. Check em, answer y or n and the changes are written to your library. Configuration ------------- From 2d77861e4d6867093873c516e8ca39bea94f5f34 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Tue, 3 Nov 2015 13:55:08 +0100 Subject: [PATCH 11/93] renamed and updated editplugin --- beetsplug/edit.py | 357 ++++++++++++++++++++++++++++++++++++++++++ docs/plugins/edit.rst | 88 +++++++++++ 2 files changed, 445 insertions(+) create mode 100644 beetsplug/edit.py create mode 100644 docs/plugins/edit.rst diff --git a/beetsplug/edit.py b/beetsplug/edit.py new file mode 100644 index 000000000..00de0f444 --- /dev/null +++ b/beetsplug/edit.py @@ -0,0 +1,357 @@ +# This file is part of beets. +# Copyright 2015 jean-marie winters +# +# 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 tags of items in texteditor,change them and save back to the items. +""" +from __future__ import (division, absolute_import, print_function, + unicode_literals) + +from beets import plugins +from beets.ui import Subcommand, decargs, library, print_ +import subprocess +import difflib +import yaml +import collections +import webbrowser +from sys import exit +from beets import config +from beets import ui +from tempfile import NamedTemporaryFile + + +class EditPlugin(plugins.BeetsPlugin): + + def __init__(self): + super(EditPlugin, self).__init__() + + self.config.add({ + 'style': 'yaml', + 'editor': '', + 'diff_method': '', + 'browser': '', + 'albumfields': 'album albumartist', + 'itemfields': 'track title artist album ', + 'not_fields': 'path', + 'separator': '-' + + }) + self.style = self.config['style'].get(unicode) + """the editor field in the config lets you specify your editor. + Defaults to open with webrowser module""" + self.editor = self.config['editor'].as_str_seq() + """the html_viewer field in your config lets you specify + your htmlviewer. Defaults to open with webrowser module""" + self.browser = self.config['browser'].as_str_seq() + """the diff_method field in your config picks the way to see your + changes. Options are: + 'ndiff'(2 files with differences), + 'unified'(just the different lines and a few lines of context), + 'html'(view in html-format), + 'vimdiff'(view in VIM)""" + self.diff_method = self.config['diff_method'].get(unicode) + """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() + '''the separator in your config sets the separator that will be used + between fields in your terminal. Defaults to -''' + self.separator = self.config['separator'].get(unicode) + self.ed = None + self.ed_args = None + self.brw = None + self.brw_args = None + + def commands(self): + edit_command = Subcommand( + 'edit', + help='send items to yamleditor for editing tags') + edit_command.parser.add_option( + '-e', '--extra', + action='store', + help='add additional fields to edit', + ) + edit_command.parser.add_option( + '--all', + action='store_true', dest='all', + help='add all fields to edit', + ) + 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 + if self.browser: + self.brw_args = self.browser[1:] if len(self.browser) > 1 else None + self.brw = self.browser[0] if self.browser else None + + # edit tags in a textfile in yaml-style + query = decargs(args) + # makes a string representation of an object + # for now yaml but we could add html,pprint,toml + self.print_items = { + 'yaml': self.print_to_yaml} + # makes an object from a string representation + # for now yaml but we could add html,pprint,toml + self.string_to_dict = { + 'yaml': self.yaml_to_dict} + # 4 ways to view the changes in objects + self.diffresults = { + 'ndiff': self.ndiff, + 'unified': self.unified, + 'html': self.html, + 'vimdiff': self.vimdiff} + # make a dictionary from the chosen fields + # you can do em all or a selection + self.make_dict = { + 'all': self.get_all_fields, + "selected": self.get_selected_fields} + + objs = self._get_objs(lib, opts, query) + if not objs: + print_('nothing found') + return + fmt = self.get_fields_from(objs, opts) + print_(fmt) + [print_(format(item, fmt)) for item in objs] + if not ui.input_yn(ui.colorize('action_default', "Edit?(n/y)"), True): + return + dict_from_objs = self.make_dict[self.pick](self.fields, objs, opts) + newyaml, oldyaml = self.change_objs(dict_from_objs) + changed_objs = self.check_diff(newyaml, oldyaml) + if not changed_objs: + print_("nothing to change") + return + self.save_items(changed_objs, lib, fmt, 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_objs(self, lib, opts, query): + # get objects from a query + if opts.album: + return list(lib.albums(query)) + else: + return list(lib.items(query)) + + def get_fields_from(self, objs, opts): + # construct a list of fields we need + cl = ui.colorize('action', self.separator) + # see if we need album or item fields + self.fields = self.albumfields if opts.album else self.itemfields + # if opts.format is given only use those fields + if opts.format: + self.fields = [] + self.fields.extend((opts.format).replace('$', "").split()) + # if opts.extra is given add those + if opts.extra: + fi = (opts.extra).replace('$', "").split() + self.fields.extend([f for f in fi if f not in self.fields]) + # make sure we got the id for identification + if 'id' not in self.fields: + self.fields.insert(0, 'id') + # we need all the fields + if opts.all: + self.fields = None + self.pick = "all" + print_(ui.colorize('text_warning', "edit all fields from:")) + if opts.album: + fmt = cl + cl.join(['$albumartist', '$album']) + else: + fmt = cl + cl.join(['$title', '$artist']) + else: + for it in self.fields: + if opts.album: + if it not in library.Album.all_keys(): + print_( + "{} not in albumfields.Removed it.".format( + ui.colorize( + 'text_warning', it))) + self.fields.remove(it) + else: + if it not in library.Item.all_keys(): + print_( + "{} not in itemfields.Removed it.".format( + ui.colorize( + 'text_warning', it))) + self.fields.remove(it) + self.pick = "selected" + fmtfields = ["$" + it for it in self.fields] + fmt = cl + cl.join(fmtfields[1:]) + + return fmt + + def get_selected_fields(self, myfields, objs, opts): + a = [] + for mod in objs: + a.append([{fi: mod[fi]}for fi in myfields]) + return a + + def get_all_fields(self, myfields, objs, opts): + a = [] + for mod in objs: + a.append([{fi: mod[fi]} for fi in sorted(mod._fields)]) + return a + + 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_items[self.style](dict_items) + newyaml = self.print_items[self.style](dict_items) + new = NamedTemporaryFile(suffix='.yaml', delete=False) + new.write(newyaml) + new.close() + if not self.ed: + webbrowser.open(new.name, new=2, autoraise=True) + else: + callmethod = [self.ed] + if self.ed_args: + callmethod.extend(self.ed_args) + callmethod.append(new.name) + subprocess.check_call(callmethod) + + if ui.input_yn(ui.colorize('action_default', "done?(y)"), True): + with open(new.name) as f: + newyaml = f.read() + return newyaml, oldyaml + else: + exit() + + def save_items(self, oldnewlist, lib, fmt, opts): + oldset = [] + newset = [] + for old, new in oldnewlist: + oldset.append(old) + newset.append(new) + + no = [] + for newitem in range(0, len(newset)): + ordict = collections.OrderedDict() + for each in newset[newitem]: + ordict.update(each) + no.append(ordict) + + changedob = [] + for each in no: + if not opts.album: + ob = lib.get_item(each['id']) + else: + ob = lib.get_album(each['id']) + ob.update(each) + changedob.append(ob) + + if self.diff_method: + ostr = self.print_items[self.style](oldset) + nwstr = self.print_items[self.style](newset) + pprint.pprint(self.diff_method) + pprint.pprint(type(self.diff_method)) + self.diffresults[self.diff_method](ostr, nwstr) + else: + for obj in changedob: + ui.show_model_changes(obj) + self.save_write(changedob) + + def save_write(self, changedob): + if not ui.input_yn('really modify? (y/n)'): + return + + for ob in changedob: + if config['import']['write'].get(bool): + ob.try_sync() + else: + ob.store() + print("changed: {0}".format(ob)) + + return + + def check_diff(self, newyaml, oldyaml): + # get the changed objects + nl = self.string_to_dict[self.style](newyaml) + ol = self.string_to_dict[self.style](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] + print_("reset forbidden field.") + if ol != nl: + return ol, nl + + def ndiff(self, newfilestr, oldfilestr): + newlines = newfilestr.splitlines() + oldlines = oldfilestr.splitlines() + diff = difflib.ndiff(newlines, oldlines) + print_('\n'.join(list(diff))) + return + + def unified(self, newfilestr, oldfilestr): + newlines = newfilestr.splitlines() + oldlines = oldfilestr.splitlines() + diff = difflib.unified_diff(newlines, oldlines, lineterm='') + print_('\n'.join(list(diff))) + return + + def html(self, newfilestr, oldfilestr): + newlines = newfilestr.splitlines() + oldlines = oldfilestr.splitlines() + diff = difflib.HtmlDiff() + pprint.pprint("here in html") + df = diff.make_file(newlines, oldlines) + ht = NamedTemporaryFile('w', suffix='.html', delete=False) + ht.write(df) + ht.flush() + hdn = ht.name + if not self.brw: + webbrowser.open(hdn, new=2, autoraise=True) + else: + callmethod = [self.brw] + if self.brw_args: + callmethod.extend(self.brw_args) + callmethod.append(hdn) + subprocess.call(callmethod) + return + + def vimdiff(self, newstringstr, oldstringstr): + + newdiff = NamedTemporaryFile(suffix='.old.yaml', delete=False) + newdiff.write(newstringstr) + newdiff.close() + olddiff = NamedTemporaryFile(suffix='.new.yaml', delete=False) + olddiff.write(oldstringstr) + olddiff.close() + subprocess.call(['vimdiff', newdiff.name, olddiff.name]) diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst new file mode 100644 index 000000000..68ec7a056 --- /dev/null +++ b/docs/plugins/edit.rst @@ -0,0 +1,88 @@ +editPlugin +============ +The ``edit`` plugin lets you open the tags, fields from a group of items, edit them in a text-editor and save them back. +Add the ``edit`` plugin to your ``plugins:`` in your ``config.yaml``. Then +you simply put in a query like you normally do. +:: + + beet yamleditor beatles + beet yamleditor beatles -a + beet yamleditor beatles -f '$title $lyrics' + + + +You get a list of hits and then you can edit them. The ``edit`` opens your standard text-editor with a list of your hits and for each hit a bunch of fields. + +Without anything specified in your ``config.yaml`` for ``edit:`` you will see + +for items +:: + + $track-$title-$artist-$album + +and for albums +:: + + $album-$albumartist + +You can get fields from the cmdline by adding +:: + + -f '$genre $added' + +or + +:: + + -e '$year $comments' + +If you use ``-f '$field ...'`` you get *only* what you specified. + +If you use ``-e '$field ...'`` you get what you specified *extra*. + +If you add ``--all`` you get all the fields. + +After you edit the values in your text-editor - *and you may only edit the values, no deleting fields or adding fields!* - you save the file, answer with ``y`` on ``Done?`` and you get a summary of your changes. Check em, answer ``y`` or ``n`` and the changes are written to your library. + +Configuration +------------- + +Make a ``edit:`` section in your config.yaml ``(beet config -e)`` +:: + + edit: + editor: nano -w -p + diff_method: html + browser: firefox -private-window + albumfields: genre album + itemfields: track artist + not_fields: id path + separator: "<>" + +* ``editor:`` pick your own texteditor; add arguments if needed. If no``editor:`` then your system opens the file-extension. + +* ``diff_method:`` 4 choices. With no ``diff_method:`` you get the beets way of showing differences. + - ``ndiff``: you see original and the changed yamls with the changes. + - ``unified``: you see the changes with a bit of context. Simple and compact. + - ``html``: a html file that you can open in a browser. Looks nice. + - ``vimdiff``: gives you VIM with the diffs.You need VIM for this. + +* ``browser:`` + If you pick ``diff_method:html`` you can specify a viewer for it (if needed add arguments). If not, let your system open the file-extension. + +* The ``albumfields:`` and ``itemfields:`` lets you list the fields you want to change. + ``albumfields:`` gets picked if you put ``-a`` in your search query, else ``itemfields:``. For a list of fields + do the ``beet fields`` command. + +* The ``not_fields:``. Fields that you put in here will not be changed. You can see them but not change them. It always contains ``id`` and standard also the ``path``. + Don't want to mess with them. + +* The default ``separator:`` prints like: +:: + + -02-The Night Before-The Beatles-Help! + +but you can pick anything else. With "<>" it will look like: +:: + + <>02<>The Night Before<>The Beatles<>Help! From 91f67146f942afeb22c99d321b3a2728f1598420 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Tue, 3 Nov 2015 14:04:39 +0100 Subject: [PATCH 12/93] Delete yamleditor.rst --- docs/plugins/yamleditor.rst | 84 ------------------------------------- 1 file changed, 84 deletions(-) delete mode 100644 docs/plugins/yamleditor.rst diff --git a/docs/plugins/yamleditor.rst b/docs/plugins/yamleditor.rst deleted file mode 100644 index d5f3641ba..000000000 --- a/docs/plugins/yamleditor.rst +++ /dev/null @@ -1,84 +0,0 @@ -Yamleditor Plugin -================= -The ``yamleditor`` plugin lets you open the tags, fields from a group of items, edit them in a text-editor and save them back. - -You simply put in a query like you normally do in beets. - - `beet yamleditor beatles` - - `beet yamleditor beatles -a` - - `beet yamleditor beatles -f'$title-$lyrics'` - - - -You get a list of hits and then you can edit them. The ``yamleditor`` opens your standard text-editor with a list of your hits and for each hit a bunch of fields. - -Without anything specified in your ``config.yaml`` for ``yamleditor:`` you will get - -for items - - `track-$title-$artist-$album` - -and for albums - - `$album-$albumartist` - -you can get fields from the cmdline by adding - - `-f '$genre $added'` - -or - - `-e '$year $comments'` - -If you use ``-f '$field ...'`` you get *only* what you specified. - -If you use ``-e '$field ...'`` you get what you specified *extra*. - -If you use ``-f or -e '$_all'`` you get all the fields. - -After you edit the values in your text-editor - *and you may only edit the values, no deleting fields or adding fields!* - you save the file, answer with y on ``Done`` and you get a summary of your changes. Check em, answer y or n and the changes are written to your library. - -Configuration -------------- - -Make a ``yamleditor:`` section in your config.yaml ``(beet config -e)`` - - yamleditor: - * editor: nano - * editor_args: - * diff_method: ndiff - * html_viewer:firefox - * html_args : - * albumfields: genre album - * itemfields: track artist - * not_fields: id path - * separator: "<>" - -* editor: you can pick your own texteditor. Defaults to systems default. -* editor_args: in case you need extra arguments for your text-editor. -* diff_method: 4 choices with no diff_method you get the beets way of showing differences. - * ndiff: you see original and the changed yaml files with the changes - * unified: you see the changes with a bit of context. Simple and compact. - * html: a html file that you can open in a browser. Looks nice. - * vimdiff: gives you VIM with the diffs - -* html_viewer: - If you pick ``html`` you can specify a viewer for it. If not the systems-default - will be picked. -* html_args: in case your html_viewer needs arguments -* The ``albumfields`` and ``itemfields`` let you put in a list of fields you want to change. - ``albumfields`` gets picked if you put -a in your search query else ``itemfields``. For a list of fields - do the ``beet fields``. - -* The ``not_fields`` always contain ``id`` and standard also the ``path``. - Don't want to mess with them. - -* The default ``separator`` prints like: - - ``-02-The Night Before-The Beatles-Help!`` - - but with ex "<>" it will look like: - - ``<>02<>The Night Before<>The Beatles<>Help!`` From 5a8a534a3ff1fac2e4e5d7004c4fcc7b1908207f Mon Sep 17 00:00:00 2001 From: jmwatte Date: Tue, 3 Nov 2015 14:05:06 +0100 Subject: [PATCH 13/93] Delete yamleditor.py --- beetsplug/yamleditor.py | 329 ---------------------------------------- 1 file changed, 329 deletions(-) delete mode 100644 beetsplug/yamleditor.py diff --git a/beetsplug/yamleditor.py b/beetsplug/yamleditor.py deleted file mode 100644 index 1124edd0e..000000000 --- a/beetsplug/yamleditor.py +++ /dev/null @@ -1,329 +0,0 @@ -# This file is part of beets. -# Copyright 2015, Jean-Marie Winters -# -# 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 tags of items in texteditor,change them and save back to the items. -""" -from __future__ import (division, absolute_import, print_function, - unicode_literals) - -from beets import plugins -from beets.ui import Subcommand, decargs, library, print_ -import subprocess -import difflib -import yaml -import collections -import webbrowser -from sys import exit -from beets import config -from beets import ui -from tempfile import NamedTemporaryFile - - -class yamleditorPlugin(plugins.BeetsPlugin): - - def __init__(self): - super(yamleditorPlugin, self).__init__() - - self.config.add({ - 'style': 'yaml', - 'editor': '', - 'diff_method': '', - 'html_viewer': '', - 'editor_args': '', - 'html_args': '', - 'albumfields': 'album albumartist', - 'itemfields': 'track title artist album ', - 'not_fields': 'path', - 'separator': '-' - - }) - - def commands(self): - yamleditor_command = Subcommand( - 'yamleditor', - help='send items to yamleditor for editing tags') - yamleditor_command.parser.add_option( - '-e', '--extra', - action='store', - help='add additional fields to edit', - ) - yamleditor_command.parser.add_all_common_options() - yamleditor_command.func = self.editor_music - return[yamleditor_command] - - def editor_music(self, lib, opts, args): - """edit tags in a textfile in yaml-style - """ - self.style = self.config['style'].get() - """the editor field in the config lets you specify your editor. - Defaults to open with webrowser module""" - self.editor = self.config['editor'].get() - """the editor_args field in your config lets you specify - additional args for your editor""" - self.editor_args = self.config['editor_args'].get().split() - """the html_viewer field in your config lets you specify - your htmlviewer. Defaults to open with webrowser module""" - self.html_viewer = self.config['html_viewer'].get() - """the html_args field in your config lets you specify - additional args for your viewer""" - self.html_args = self.config['html_args'].get().split() - """the diff_method field in your config picks the way to see your - changes. Options are: - 'ndiff'(2 files with differences), - 'unified'(just the different lines and a few lines of context), - 'html'(view in html-format), - 'vimdiff'(view in VIM)""" - self.diff_method = self.config['diff_method'].get() - """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'].get().split() - """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'].get().split() - '''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'].get().split() - '''the separator in your config sets the separator that will be used - between fields in your terminal. Defaults to -''' - self.separator = self.config['separator'].get() - - query = decargs(args) - self.print_items = { - 'yaml': self.print_to_yaml} - self.diffresults = { - 'ndiff': self.ndiff, - 'unified': self.unified, - 'html': self.html, - 'vimdiff': self.vimdiff} - self.make_dict = { - 'all': self.get_all_fields, - "selected": self.get_selected_fields} - self.string_to_dict = { - 'yaml': self.yaml_to_dict} - - objs = self._get_objs(lib, opts, query) - if not objs: - print_('nothing found') - return - fmt = self.get_fields_from(objs, opts) - print_(fmt) - [print_(format(item, fmt)) for item in objs] - if not ui.input_yn(ui.colorize('action_default', "Edit?(n/y)"), True): - return - dict_from_objs = self.make_dict[self.pick](self.fields, objs, opts) - newyaml, oldyaml = self.change_objs(dict_from_objs) - changed_objs = self.check_diff(newyaml, oldyaml) - if not changed_objs: - print_("nothing to change") - return - self.save_items(changed_objs, lib, fmt, opts) - - '''from object to yaml''' - def print_to_yaml(self, arg): - return yaml.safe_dump_all( - arg, - allow_unicode=True, - default_flow_style=False) - - '''from yaml to object''' - def yaml_to_dict(self, yam): - return yaml.load_all(yam) - - def _get_objs(self, lib, opts, query): - if opts.album: - return list(lib.albums(query)) - else: - return list(lib.items(query)) - - def get_fields_from(self, objs, opts): - cl = ui.colorize('action', self.separator) - self.fields = self.albumfields if opts.album else self.itemfields - if opts.format: - self.fields = [] - self.fields.extend((opts.format).replace('$', "").split()) - if opts.extra: - fi = (opts.extra).replace('$', "").split() - self.fields.extend([f for f in fi if f not in self.fields]) - if 'id' not in self.fields: - self.fields.insert(0, 'id') - if "_all" in self.fields: - self.fields = None - self.pick = "all" - print_(ui.colorize('text_warning', "edit all fields from ...")) - if opts.album: - fmt = cl + cl.join(['$albumartist', '$album']) - else: - fmt = cl + cl.join(['$title', '$artist']) - else: - for it in self.fields: - if opts.album: - if it not in library.Album.all_keys(): - print_( - "{} not in albumfields.Removed it.".format( - ui.colorize( - 'text_warning', it))) - self.fields.remove(it) - else: - if it not in library.Item.all_keys(): - print_( - "{} not in itemfields.Removed it.".format( - ui.colorize( - 'text_warning', it))) - self.fields.remove(it) - self.pick = "selected" - fmtfields = ["$" + it for it in self.fields] - fmt = cl + cl.join(fmtfields[1:]) - - return fmt - - '''get the fields we want and make a dic from them''' - def get_selected_fields(self, myfields, objs, opts): - a = [] - for mod in objs: - a.append([{fi: mod[fi]}for fi in myfields]) - return a - - def get_all_fields(self, myfields, objs, opts): - a = [] - for mod in objs: - a.append([{fi: mod[fi]} for fi in sorted(mod._fields)]) - return a - - def change_objs(self, dict_items): - oldyaml = self.print_items[self.style](dict_items) - newyaml = self.print_items[self.style](dict_items) - new = NamedTemporaryFile(suffix='.yaml', delete=False) - new.write(newyaml) - new.close() - if not self.editor: - webbrowser.open(new.name, new=2, autoraise=True) - if self.editor and not self.editor_args: - subprocess.check_call([self.editor, new.name]) - elif self.editor and self.editor_args: - subprocess.check_call( - [self.editor, new.name, self.editor_args]) - - if ui.input_yn(ui.colorize('action_default', "done?(y)"), True): - with open(new.name) as f: - newyaml = f.read() - return newyaml, oldyaml - else: - exit() - - def save_items(self, oldnewlist, lib, fmt, opts): - oldset = [] - newset = [] - for old, new in oldnewlist: - oldset.append(old) - newset.append(new) - - no = [] - for newitem in range(0, len(newset)): - ordict = collections.OrderedDict() - for each in newset[newitem]: - ordict.update(each) - no.append(ordict) - - changedob = [] - for each in no: - if not opts.album: - ob = lib.get_item(each['id']) - else: - ob = lib.get_album(each['id']) - ob.update(each) - changedob.append(ob) - - if self.diff_method: - ostr = self.print_items[self.style](oldset) - nwstr = self.print_items[self.style](newset) - self.diffresults[self.diff_method](ostr, nwstr) - else: - for obj in changedob: - ui.show_model_changes(obj) - self.save_write(changedob) - - def save_write(self, changedob): - if not ui.input_yn('really modify? (y/n)'): - return - - for ob in changedob: - if config['import']['write'].get(bool): - ob.try_sync() - else: - ob.store() - print("changed: {0}".format(ob)) - - return - - def check_diff(self, newyaml, oldyaml): - nl = self.string_to_dict[self.style](newyaml) - ol = self.string_to_dict[self.style](oldyaml) - return filter(None, map(self.reduce_it, ol, nl)) - - '''if there is a forbidden field it gathers them here(check_ids)''' - def reduce_it(self, ol, nl): - 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] - print_("reset forbidden field.") - if ol != nl: - return ol, nl - - def ndiff(self, newfilestr, oldfilestr): - newlines = newfilestr.splitlines() - oldlines = oldfilestr.splitlines() - diff = difflib.ndiff(newlines, oldlines) - print_('\n'.join(list(diff))) - return - - def unified(self, newfilestr, oldfilestr): - newlines = newfilestr.splitlines() - oldlines = oldfilestr.splitlines() - diff = difflib.unified_diff(newlines, oldlines, lineterm='') - print_('\n'.join(list(diff))) - return - - def html(self, newfilestr, oldfilestr): - newlines = newfilestr.splitlines() - oldlines = oldfilestr.splitlines() - diff = difflib.HtmlDiff() - df = diff.make_file(newlines, oldlines) - ht = NamedTemporaryFile('w', suffix='.html', delete=False) - ht.write(df) - hdn = ht.name - if not self.html_viewer: - webbrowser.open(hdn, new=2, autoraise=True) - else: - callmethod = [self.html_viewer] - callmethod.extend(self.html_args) - callmethod.append(hdn) - subprocess.call(callmethod) - return - - def vimdiff(self, newstringstr, oldstringstr): - - newdiff = NamedTemporaryFile(suffix='.old.yaml', delete=False) - newdiff.write(newstringstr) - newdiff.close() - olddiff = NamedTemporaryFile(suffix='.new.yaml', delete=False) - olddiff.write(oldstringstr) - olddiff.close() - subprocess.call(['vimdiff', newdiff.name, olddiff.name]) From 76509e168224d91b40660cea15417fedd02cdb88 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Tue, 3 Nov 2015 14:37:34 +0100 Subject: [PATCH 14/93] Update edit.rst polishing --- docs/plugins/edit.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst index 68ec7a056..771d600fd 100644 --- a/docs/plugins/edit.rst +++ b/docs/plugins/edit.rst @@ -1,11 +1,11 @@ -editPlugin +Edit Plugin ============ The ``edit`` plugin lets you open the tags, fields from a group of items, edit them in a text-editor and save them back. Add the ``edit`` plugin to your ``plugins:`` in your ``config.yaml``. Then you simply put in a query like you normally do. :: - beet yamleditor beatles + beet yamleditor beatles beet yamleditor beatles -a beet yamleditor beatles -f '$title $lyrics' @@ -82,6 +82,7 @@ Make a ``edit:`` section in your config.yaml ``(beet config -e)`` -02-The Night Before-The Beatles-Help! + but you can pick anything else. With "<>" it will look like: :: From 1f3a42faf0a2e5435e37e61f42568823b937cd01 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Tue, 3 Nov 2015 14:57:07 +0100 Subject: [PATCH 15/93] Update edit.py --- beetsplug/edit.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 00de0f444..8fc57e087 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -275,8 +275,6 @@ class EditPlugin(plugins.BeetsPlugin): if self.diff_method: ostr = self.print_items[self.style](oldset) nwstr = self.print_items[self.style](newset) - pprint.pprint(self.diff_method) - pprint.pprint(type(self.diff_method)) self.diffresults[self.diff_method](ostr, nwstr) else: for obj in changedob: @@ -330,7 +328,6 @@ class EditPlugin(plugins.BeetsPlugin): newlines = newfilestr.splitlines() oldlines = oldfilestr.splitlines() diff = difflib.HtmlDiff() - pprint.pprint("here in html") df = diff.make_file(newlines, oldlines) ht = NamedTemporaryFile('w', suffix='.html', delete=False) ht.write(df) From 8b23c893187f2d2734791d747c57c9db6d9a1f38 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Tue, 3 Nov 2015 15:03:14 +0100 Subject: [PATCH 16/93] Update edit.rst polishing --- docs/plugins/edit.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst index 771d600fd..125f7ce0b 100644 --- a/docs/plugins/edit.rst +++ b/docs/plugins/edit.rst @@ -78,6 +78,7 @@ Make a ``edit:`` section in your config.yaml ``(beet config -e)`` Don't want to mess with them. * The default ``separator:`` prints like: + :: -02-The Night Before-The Beatles-Help! From 67a46b6e080ee4347a2ab4c5adc52a48a433932b Mon Sep 17 00:00:00 2001 From: jmwatte Date: Tue, 3 Nov 2015 15:13:01 +0100 Subject: [PATCH 17/93] Update changelog.rst change name from yamleditor to edit --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8182bdd69..9282b099c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,8 +3,8 @@ Changelog 1.3.16 (in development) ----------------------- -* A new plugin yamleditor helps you manually edit fields from items. - You search for items in the normal beets way.Then yamleditor opens a texteditor +* A new plugin edit helps you manually edit fields from items. + You search for items in the normal beets way.Then edit opens a texteditor with the items and the fields of the items you want to edit. Afterwards you can review your changes save them back into the items. From afedfbf2aa3f22fc87f3c3d9885cb6c0a63102f9 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Wed, 4 Nov 2015 09:11:46 +0100 Subject: [PATCH 18/93] Update index.rst added the edit plugin --- docs/plugins/index.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 0c95f366f..a4767cc22 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -40,6 +40,7 @@ Each plugin has its own set of options that can be defined in a section bearing discogs duplicates echonest + edit embedart fetchart fromfilename @@ -95,6 +96,7 @@ Metadata * :doc:`bpm`: Measure tempo using keystrokes. * :doc:`echonest`: Automatically fetch `acoustic attributes`_ from `the Echo Nest`_ (tempo, energy, danceability, ...). +* :doc:`edit`: Edit metadata from a texteditor. * :doc:`embedart`: Embed album art images into files' metadata. * :doc:`fetchart`: Fetch album cover art from various sources. * :doc:`ftintitle`: Move "featured" artists from the artist field to the title From 17a32e33c5a8cfe9d908682eaa7cead007382a45 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Thu, 5 Nov 2015 15:30:01 +0100 Subject: [PATCH 19/93] added grouping of fields and "$BROWSER" and "$EDITOR" --- beetsplug/edit.py | 186 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 137 insertions(+), 49 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 8fc57e087..aa8ede708 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2015 jean-marie winters +# Copyright 2015 # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -28,6 +28,7 @@ from sys import exit from beets import config from beets import ui from tempfile import NamedTemporaryFile +import os class EditPlugin(plugins.BeetsPlugin): @@ -42,8 +43,8 @@ class EditPlugin(plugins.BeetsPlugin): 'browser': '', 'albumfields': 'album albumartist', 'itemfields': 'track title artist album ', - 'not_fields': 'path', - 'separator': '-' + 'not_fields': 'id path', + 'sep': '-' }) self.style = self.config['style'].get(unicode) @@ -77,7 +78,7 @@ class EditPlugin(plugins.BeetsPlugin): self.not_fields = self.config['not_fields'].as_str_seq() '''the separator in your config sets the separator that will be used between fields in your terminal. Defaults to -''' - self.separator = self.config['separator'].get(unicode) + self.sep = self.config['sep'].get(unicode) self.ed = None self.ed_args = None self.brw = None @@ -97,6 +98,11 @@ class EditPlugin(plugins.BeetsPlugin): action='store_true', dest='all', help='add all fields to edit', ) + edit_command.parser.add_option( + '--sum', + action='store_true', dest='sum', + help='groups fields with the same value', + ) edit_command.parser.add_all_common_options() edit_command.func = self.editor_music return[edit_command] @@ -142,7 +148,7 @@ class EditPlugin(plugins.BeetsPlugin): return dict_from_objs = self.make_dict[self.pick](self.fields, objs, opts) newyaml, oldyaml = self.change_objs(dict_from_objs) - changed_objs = self.check_diff(newyaml, oldyaml) + changed_objs = self.check_diff(newyaml, oldyaml, opts) if not changed_objs: print_("nothing to change") return @@ -168,7 +174,7 @@ class EditPlugin(plugins.BeetsPlugin): def get_fields_from(self, objs, opts): # construct a list of fields we need - cl = ui.colorize('action', self.separator) + cl = ui.colorize('action', self.sep) # see if we need album or item fields self.fields = self.albumfields if opts.album else self.itemfields # if opts.format is given only use those fields @@ -215,15 +221,37 @@ class EditPlugin(plugins.BeetsPlugin): def get_selected_fields(self, myfields, objs, opts): a = [] - for mod in objs: - a.append([{fi: mod[fi]}for fi in myfields]) - return a + if opts.sum: + for field in myfields: + if field not in self.not_fields: + d = collections.defaultdict(str) + for obj in objs: + d[obj[field]] += (" " + str(obj['id'])) + a.append([field, [{f: i} for f, i in d.items()]]) + return a + else: + for mod in objs: + a.append([{fi: mod[fi]}for fi in myfields]) + return a def get_all_fields(self, myfields, objs, opts): a = [] - for mod in objs: - a.append([{fi: mod[fi]} for fi in sorted(mod._fields)]) - return a + if opts.sum: + fields = (library.Album.all_keys() if opts.album + else library.Item.all_keys()) + for field in sorted(fields): + # for every field get a dict + d = collections.defaultdict(str) + for obj in objs: + # put all the ob-ids in the dict[field] as a string + d[obj[field]] += (" " + str(obj['id'])) + # for the field, we get the value and the users + a.append([field, [{f: i} for f, i in sorted(d.items())]]) + return a + else: + for mod in objs: + a.append([{fi: mod[fi]} for fi in sorted(mod._fields)]) + return a def change_objs(self, dict_items): # construct a yaml from the original object-fields @@ -233,48 +261,82 @@ class EditPlugin(plugins.BeetsPlugin): new = NamedTemporaryFile(suffix='.yaml', delete=False) new.write(newyaml) new.close() - if not self.ed: - webbrowser.open(new.name, new=2, autoraise=True) - else: - callmethod = [self.ed] - if self.ed_args: - callmethod.extend(self.ed_args) - callmethod.append(new.name) - subprocess.check_call(callmethod) + self.get_editor(new.name) if ui.input_yn(ui.colorize('action_default', "done?(y)"), True): - with open(new.name) as f: - newyaml = f.read() + while True: + try: + with open(new.name) as f: + newyaml = f.read() + list(yaml.load_all(newyaml)) + break + except yaml.YAMLError as e: + print_(ui.colorize('text_warning', + "change this fault: {}".format(e))) + print_("correct format for empty = - '' :") + if ui.input_yn( + ui.colorize('action_default', "fix?(y)"), True): + self.get_editor(new.name) + if ui.input_yn(ui.colorize( + 'action_default', "ok.fixed.(y)"), True): + pass + return newyaml, oldyaml else: exit() - def save_items(self, oldnewlist, lib, fmt, opts): - oldset = [] - newset = [] - for old, new in oldnewlist: - oldset.append(old) - newset.append(new) - - no = [] - for newitem in range(0, len(newset)): - ordict = collections.OrderedDict() - for each in newset[newitem]: - ordict.update(each) - no.append(ordict) - - changedob = [] - for each in no: - if not opts.album: - ob = lib.get_item(each['id']) + def get_editor(self, name): + if not self.ed: + editor = os.getenv('EDITOR') + if editor: + os.system(editor + " " + name) else: - ob = lib.get_album(each['id']) - ob.update(each) - changedob.append(ob) + webbrowser.open(name, new=2, autoraise=True) + else: + callmethod = [self.ed] + if self.ed_args: + callmethod.extend(self.ed_args) + callmethod.append(name) + subprocess.check_call(callmethod) + def same_format(self, newset, opts): + alld = collections.defaultdict(dict) + if opts.sum: + for o in newset: + for so in o: + ids = set((so[1].values()[0].split())) + for id in ids: + alld[id].update( + {so[0].items()[0][1]: so[1].items()[0][0]}) + else: + for o in newset: + for so in o: + alld[o[0].values()[0]].update(so) + return alld + + def save_items(self, oldnewlist, lib, fmt, opts): + + oldset, newset = zip(*oldnewlist) + no = self.same_format(newset, opts) + oo = self.same_format(oldset, opts) + ono = zip(oo.items(), no.items()) + nl = [] + ol = [] + changedob = [] + for o, n in ono: + if not opts.album: + ob = lib.get_item(n[0]) + else: + ob = lib.get_album(n[0]) + # change id to item-string + ol.append((format(ob),) + o[1:]) + ob.update(n[1]) + nl.append((format(ob),) + n[1:]) + changedob.append(ob) + # see the changes we made if self.diff_method: - ostr = self.print_items[self.style](oldset) - nwstr = self.print_items[self.style](newset) + ostr = self.print_items[self.style](ol) + nwstr = self.print_items[self.style](nl) self.diffresults[self.diff_method](ostr, nwstr) else: for obj in changedob: @@ -294,11 +356,33 @@ class EditPlugin(plugins.BeetsPlugin): return - def check_diff(self, newyaml, oldyaml): - # get the changed objects + def check_diff(self, newyaml, oldyaml, opts): + # make python objs from yamlstrings nl = self.string_to_dict[self.style](newyaml) ol = self.string_to_dict[self.style](oldyaml) - return filter(None, map(self.reduce_it, ol, nl)) + if opts.sum: + return filter(None, map(self.reduce_sum, ol, nl)) + else: + return filter(None, map(self.reduce_it, ol, nl)) + + def reduce_sum(self, ol, nl): + # only get the changed objs + if ol != nl: + sol = [i for i in ol[1]] + snl = [i for i in nl[1]] + a = filter(None, map(self.reduce_sub, sol, snl)) + header = {"field": ol[0]} + ol = [[header, b[0]] for b in a] + nl = [[header, b[1]] for b in a] + return ol, nl + + def reduce_sub(self, sol, snl): + # if the keys have changed resets them + if sol != snl: + if snl.values() != sol.values(): + snl[snl.keys()[0]] = sol[sol.keys()[0]] + if sol != snl: + return sol, snl def reduce_it(self, ol, nl): # if there is a forbidden field it resets them @@ -334,7 +418,11 @@ class EditPlugin(plugins.BeetsPlugin): ht.flush() hdn = ht.name if not self.brw: - webbrowser.open(hdn, new=2, autoraise=True) + browser = os.getenv('BROWSER') + if browser: + os.system(browser + " " + hdn) + else: + webbrowser.open(hdn, new=2, autoraise=True) else: callmethod = [self.brw] if self.brw_args: From f95611dde4b2610790923d2922c0c0a1c6a547f2 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Thu, 5 Nov 2015 16:51:33 +0100 Subject: [PATCH 20/93] fixed albumid needs to be int --- beetsplug/edit.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index aa8ede708..3166ce15d 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -101,7 +101,7 @@ class EditPlugin(plugins.BeetsPlugin): edit_command.parser.add_option( '--sum', action='store_true', dest='sum', - help='groups fields with the same value', + help='list fields with the same value', ) edit_command.parser.add_all_common_options() edit_command.func = self.editor_music @@ -324,10 +324,10 @@ class EditPlugin(plugins.BeetsPlugin): ol = [] changedob = [] for o, n in ono: - if not opts.album: - ob = lib.get_item(n[0]) + if opts.album: + ob = lib.get_album(int(n[0])) else: - ob = lib.get_album(n[0]) + ob = lib.get_item(n[0]) # change id to item-string ol.append((format(ob),) + o[1:]) ob.update(n[1]) From fefb2e9914381ac022b88bbd78ee2abe2dca073c Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 7 Nov 2015 12:36:22 -0800 Subject: [PATCH 21/93] A little cleanup for legibility and style --- beetsplug/edit.py | 69 ++++++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 3166ce15d..597cf6b6f 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -12,7 +12,8 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""open tags of items in texteditor,change them and save back to the items. +"""Open metadata information in a text editor to let the user change +them directly. """ from __future__ import (division, absolute_import, print_function, unicode_literals) @@ -42,43 +43,51 @@ class EditPlugin(plugins.BeetsPlugin): 'diff_method': '', 'browser': '', 'albumfields': 'album albumartist', - 'itemfields': 'track title artist album ', + 'itemfields': 'track title artist album', 'not_fields': 'id path', - 'sep': '-' - + 'sep': '-', }) + self.style = self.config['style'].get(unicode) - """the editor field in the config lets you specify your editor. - Defaults to open with webrowser module""" + + # The editor field in the config lets you specify your editor. + # Defaults to open with webrowser module. self.editor = self.config['editor'].as_str_seq() - """the html_viewer field in your config lets you specify - your htmlviewer. Defaults to open with webrowser module""" + + # the html_viewer field in your config lets you specify + # your htmlviewer. Defaults to open with webrowser module self.browser = self.config['browser'].as_str_seq() - """the diff_method field in your config picks the way to see your - changes. Options are: - 'ndiff'(2 files with differences), - 'unified'(just the different lines and a few lines of context), - 'html'(view in html-format), - 'vimdiff'(view in VIM)""" + + # the diff_method field in your config picks the way to see your + # changes. Options are: + # 'ndiff'(2 files with differences), + # 'unified'(just the different lines and a few lines of context), + # 'html'(view in html-format), + # 'vimdiff'(view in VIM) self.diff_method = self.config['diff_method'].get(unicode) - """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""" + + # 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""" + + # 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.''' + + # 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() - '''the separator in your config sets the separator that will be used - between fields in your terminal. Defaults to -''' + + # the separator in your config sets the separator that will be used + # between fields in your terminal. Defaults to - self.sep = self.config['sep'].get(unicode) + self.ed = None self.ed_args = None self.brw = None @@ -87,7 +96,7 @@ class EditPlugin(plugins.BeetsPlugin): def commands(self): edit_command = Subcommand( 'edit', - help='send items to yamleditor for editing tags') + help='interactively edit metadata') edit_command.parser.add_option( '-e', '--extra', action='store', @@ -96,7 +105,7 @@ class EditPlugin(plugins.BeetsPlugin): edit_command.parser.add_option( '--all', action='store_true', dest='all', - help='add all fields to edit', + help='edit all fields', ) edit_command.parser.add_option( '--sum', From 7de33c83c4eacc5b02f0cf99dd121f63f9d440d9 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 7 Nov 2015 12:39:31 -0800 Subject: [PATCH 22/93] Remove an unused parameter --- beetsplug/edit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 597cf6b6f..be4354594 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -161,7 +161,7 @@ class EditPlugin(plugins.BeetsPlugin): if not changed_objs: print_("nothing to change") return - self.save_items(changed_objs, lib, fmt, opts) + self.save_items(changed_objs, lib, opts) def print_to_yaml(self, arg): # from object to yaml @@ -323,7 +323,7 @@ class EditPlugin(plugins.BeetsPlugin): alld[o[0].values()[0]].update(so) return alld - def save_items(self, oldnewlist, lib, fmt, opts): + def save_items(self, oldnewlist, lib, opts): oldset, newset = zip(*oldnewlist) no = self.same_format(newset, opts) From 519df6da047d447b761e63a9219fb1fed7383b48 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 7 Nov 2015 12:45:39 -0800 Subject: [PATCH 23/93] Use standard output format for confirmation --- beetsplug/edit.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index be4354594..6c20dc41d 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -150,11 +150,14 @@ class EditPlugin(plugins.BeetsPlugin): if not objs: print_('nothing found') return - fmt = self.get_fields_from(objs, opts) - print_(fmt) - [print_(format(item, fmt)) for item in objs] - if not ui.input_yn(ui.colorize('action_default', "Edit?(n/y)"), True): + self.get_fields_from(objs, opts) + + # Confirm. + for obj in objs: + print_(format(obj)) + if not ui.input_yn(ui.colorize('action_default', "Edit? (y/n)"), True): return + dict_from_objs = self.make_dict[self.pick](self.fields, objs, opts) newyaml, oldyaml = self.change_objs(dict_from_objs) changed_objs = self.check_diff(newyaml, oldyaml, opts) @@ -183,7 +186,6 @@ class EditPlugin(plugins.BeetsPlugin): def get_fields_from(self, objs, opts): # construct a list of fields we need - cl = ui.colorize('action', self.sep) # see if we need album or item fields self.fields = self.albumfields if opts.album else self.itemfields # if opts.format is given only use those fields @@ -202,10 +204,6 @@ class EditPlugin(plugins.BeetsPlugin): self.fields = None self.pick = "all" print_(ui.colorize('text_warning', "edit all fields from:")) - if opts.album: - fmt = cl + cl.join(['$albumartist', '$album']) - else: - fmt = cl + cl.join(['$title', '$artist']) else: for it in self.fields: if opts.album: @@ -223,10 +221,6 @@ class EditPlugin(plugins.BeetsPlugin): 'text_warning', it))) self.fields.remove(it) self.pick = "selected" - fmtfields = ["$" + it for it in self.fields] - fmt = cl + cl.join(fmtfields[1:]) - - return fmt def get_selected_fields(self, myfields, objs, opts): a = [] From 417eb5e58892f01cf9bb993e95b475dd59143c72 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 7 Nov 2015 12:46:35 -0800 Subject: [PATCH 24/93] Remove use of nonexistent option --- beetsplug/edit.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 6c20dc41d..549f21929 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -188,10 +188,6 @@ class EditPlugin(plugins.BeetsPlugin): # construct a list of fields we need # see if we need album or item fields self.fields = self.albumfields if opts.album else self.itemfields - # if opts.format is given only use those fields - if opts.format: - self.fields = [] - self.fields.extend((opts.format).replace('$', "").split()) # if opts.extra is given add those if opts.extra: fi = (opts.extra).replace('$', "").split() From 331ced3cca607d1ea9a8255a054c5b8f67adb77f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 7 Nov 2015 12:51:22 -0800 Subject: [PATCH 25/93] Use existing query mechanism --- beetsplug/edit.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 549f21929..28e6eaf70 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -20,6 +20,7 @@ from __future__ import (division, absolute_import, print_function, from beets import plugins from beets.ui import Subcommand, decargs, library, print_ +from beets.ui.commands import _do_query import subprocess import difflib import yaml @@ -124,8 +125,15 @@ class EditPlugin(plugins.BeetsPlugin): self.brw_args = self.browser[1:] if len(self.browser) > 1 else None self.brw = self.browser[0] if self.browser else None - # edit tags in a textfile in yaml-style + # Get the objects to edit. query = decargs(args) + items, albums = _do_query(lib, query, opts.album, False) + objs = albums if opts.album else items + if not objs: + print_('Nothing to edit.') + return + + # edit tags in a textfile in yaml-style # makes a string representation of an object # for now yaml but we could add html,pprint,toml self.print_items = { @@ -146,10 +154,6 @@ class EditPlugin(plugins.BeetsPlugin): 'all': self.get_all_fields, "selected": self.get_selected_fields} - objs = self._get_objs(lib, opts, query) - if not objs: - print_('nothing found') - return self.get_fields_from(objs, opts) # Confirm. @@ -177,13 +181,6 @@ class EditPlugin(plugins.BeetsPlugin): # from yaml to object return yaml.load_all(yam) - def _get_objs(self, lib, opts, query): - # get objects from a query - if opts.album: - return list(lib.albums(query)) - else: - return list(lib.items(query)) - def get_fields_from(self, objs, opts): # construct a list of fields we need # see if we need album or item fields From a71b2e1046277dbf1551f4f9fc23196231c72759 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Sun, 8 Nov 2015 16:09:41 +0100 Subject: [PATCH 26/93] editor: update plugin name on examples on docs --- docs/plugins/edit.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst index 125f7ce0b..3b48635c4 100644 --- a/docs/plugins/edit.rst +++ b/docs/plugins/edit.rst @@ -5,9 +5,9 @@ Add the ``edit`` plugin to your ``plugins:`` in your ``config.yaml``. Then you simply put in a query like you normally do. :: - beet yamleditor beatles - beet yamleditor beatles -a - beet yamleditor beatles -f '$title $lyrics' + beet edit beatles + beet edit beatles -a + beet edit beatles -f '$title $lyrics' From 1c09eeb714f31e275e46288bcfd0edafc1522bd2 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Sun, 8 Nov 2015 19:26:35 +0100 Subject: [PATCH 27/93] edit: delete temporary files, minor style fixes * Delete NamedTemporaryFiles once they are not needed on several functions (change_objs(), vimdiff(), html()). * Fix use of reserved word "id" on same_format(). * Colorize "really modify" prompt with action_default. --- beetsplug/edit.py | 57 ++++++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 28e6eaf70..62dcd8bdc 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -26,6 +26,7 @@ import difflib import yaml import collections import webbrowser +from contextlib import nested from sys import exit from beets import config from beets import ui @@ -265,6 +266,7 @@ class EditPlugin(plugins.BeetsPlugin): with open(new.name) as f: newyaml = f.read() list(yaml.load_all(newyaml)) + os.remove(new.name) break except yaml.YAMLError as e: print_(ui.colorize('text_warning', @@ -279,6 +281,7 @@ class EditPlugin(plugins.BeetsPlugin): return newyaml, oldyaml else: + os.remove(new.name) exit() def get_editor(self, name): @@ -301,8 +304,8 @@ class EditPlugin(plugins.BeetsPlugin): for o in newset: for so in o: ids = set((so[1].values()[0].split())) - for id in ids: - alld[id].update( + for id_ in ids: + alld[id_].update( {so[0].items()[0][1]: so[1].items()[0][0]}) else: for o in newset: @@ -340,7 +343,8 @@ class EditPlugin(plugins.BeetsPlugin): self.save_write(changedob) def save_write(self, changedob): - if not ui.input_yn('really modify? (y/n)'): + if not ui.input_yn(ui.colorize('action_default', + 'really modify? (y/n)')): return for ob in changedob: @@ -409,30 +413,31 @@ class EditPlugin(plugins.BeetsPlugin): oldlines = oldfilestr.splitlines() diff = difflib.HtmlDiff() df = diff.make_file(newlines, oldlines) - ht = NamedTemporaryFile('w', suffix='.html', delete=False) - ht.write(df) - ht.flush() - hdn = ht.name - if not self.brw: - browser = os.getenv('BROWSER') - if browser: - os.system(browser + " " + hdn) + + with NamedTemporaryFile('w', suffix='.html', bufsize=0) as ht: + # TODO: if webbrowser.open() is not blocking, ht might be deleted + # too soon - need to test + ht.write(df) + if not self.brw: + browser = os.getenv('BROWSER') + if browser: + os.system(browser + " " + ht.name) + else: + webbrowser.open(ht.name, new=2, autoraise=True) else: - webbrowser.open(hdn, new=2, autoraise=True) - else: - callmethod = [self.brw] - if self.brw_args: - callmethod.extend(self.brw_args) - callmethod.append(hdn) - subprocess.call(callmethod) + callmethod = [self.brw] + if self.brw_args: + callmethod.extend(self.brw_args) + callmethod.append(ht.name) + subprocess.call(callmethod) + return def vimdiff(self, newstringstr, oldstringstr): - - newdiff = NamedTemporaryFile(suffix='.old.yaml', delete=False) - newdiff.write(newstringstr) - newdiff.close() - olddiff = NamedTemporaryFile(suffix='.new.yaml', delete=False) - olddiff.write(oldstringstr) - olddiff.close() - subprocess.call(['vimdiff', newdiff.name, olddiff.name]) + with nested(NamedTemporaryFile(suffix='.old.yaml', + bufsize=0), + NamedTemporaryFile(suffix='.new.yaml', + bufsize=0)) as (newdiff, olddiff): + newdiff.write(newstringstr) + olddiff.write(oldstringstr) + subprocess.call(['vimdiff', newdiff.name, olddiff.name]) From 41b4987990e3965524ad8c951af4465c78e83a6d Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Mon, 9 Nov 2015 20:43:40 +0100 Subject: [PATCH 28/93] edit: add initial black-box tests * Add tests for the edit plugin, including a helper for bypassing the manual yaml editing. Tests are focused on "black-box" functionality, running the command and making assertions on the changes made to the library. --- test/test_edit.py | 172 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 test/test_edit.py diff --git a/test/test_edit.py b/test/test_edit.py new file mode 100644 index 000000000..90048b96a --- /dev/null +++ b/test/test_edit.py @@ -0,0 +1,172 @@ +# This file is part of beets. +# Copyright 2015, Adrian Sampson and Diego Moreda. +# +# 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. + +from __future__ import (division, absolute_import, print_function, + unicode_literals) +import codecs + +from mock import Mock, patch +from test._common import unittest +from test.helper import TestHelper, control_stdin + +from beets import library +from beetsplug.edit import EditPlugin + + +class ModifyFileMocker(object): + """Helper for modifying a file, replacing or editing its contents. Used for + mocking the calls to the external editor during testing.""" + + def __init__(self, contents=None, replacements=None): + """ `self.contents` and `self.replacements` are initalized here, in + order to keep the rest of the functions of this class with the same + signature as `EditPlugin.get_editor()`, making mocking easier. + - `contents`: string with the contents of the file to be used for + `overwrite_contents()` + - `replacement`: dict with the in-place replacements to be used for + `replace_contents()`, in the form {'previous string': 'new string'} + + TODO: check if it can be solved more elegantly with a decorator + """ + self.contents = contents + self.replacements = replacements + self.action = self.overwrite_contents + if replacements: + self.action = self.replace_contents + + def overwrite_contents(self, filename): + """Modify `filename`, replacing its contents with `self.contents`. If + `self.contents` is empty, the file remains unchanged. + """ + if self.contents: + with codecs.open(filename, 'w', encoding='utf8') as f: + f.write(self.contents) + + def replace_contents(self, filename): + """Modify `filename`, reading its contents and replacing the strings + specified in `self.replacements`. + """ + with codecs.open(filename, 'r', encoding='utf8') as f: + contents = f.read() + for old, new_ in self.replacements.iteritems(): + contents = contents.replace(old, new_) + with codecs.open(filename, 'w', encoding='utf8') as f: + f.write(contents) + + +class EditCommandTest(unittest.TestCase, TestHelper): + """ Black box tests for `beetsplug.edit`. Command line interaction is + simulated using `test.helper.control_stdin()`, and yaml editing via an + external editor is simulated using `ModifyFileMocker`. + """ + ALBUM_COUNT = 1 + TRACK_COUNT = 10 + + def setUp(self): + self.setup_beets() + self.load_plugins('edit') + # make sure that we avoid invoking the editor except for making changes + self.config['edit']['diff_method'] = '' + # add an album + self.add_album_fixture(track_count=self.TRACK_COUNT) + # keep track of write()s + library.Item.write = Mock() + + def tearDown(self): + self.teardown_beets() + self.unload_plugins() + + def run_mocked_command(self, modify_file_args={}, stdin=[]): + """Run the edit command, with mocked stdin and yaml writing.""" + m = ModifyFileMocker(**modify_file_args) + with patch.object(EditPlugin, 'get_editor', side_effect=m.action): + with control_stdin('\n'.join(stdin)): + self.run_command('edit') + + def assertCounts(self, album_count=ALBUM_COUNT, track_count=TRACK_COUNT, + write_call_count=TRACK_COUNT, title_starts_with=''): + """Several common assertions on Album, Track and call counts.""" + self.assertEqual(len(self.lib.albums()), album_count) + self.assertEqual(len(self.lib.items()), track_count) + self.assertEqual(library.Item.write.call_count, write_call_count) + self.assertTrue(all(i.title.startswith(title_starts_with) + for i in self.lib.items())) + + def test_title_edit_discard(self): + """Edit title for all items in the library, then discard changes-""" + # edit titles + self.run_mocked_command({'replacements': {u't\u00eftle': + u'modified t\u00eftle'}}, + # edit? y, done? y, modify? n + ['y', 'y', 'n']) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + + def test_title_edit_apply(self): + """Edit title for all items in the library, then apply changes.""" + # edit titles + self.run_mocked_command({'replacements': {u't\u00eftle': + u'modified t\u00eftle'}}, + # edit? y, done? y, modify? y + ['y', 'y', 'y']) + + self.assertCounts(write_call_count=self.TRACK_COUNT, + title_starts_with=u'modified t\u00eftle') + + def test_noedit(self): + """Do not edit anything.""" + # do not edit anything + self.run_mocked_command({'contents': None}, + # edit? n -> "no changes found" + ['n']) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + + def test_malformed_yaml_discard(self): + """Edit the yaml file incorrectly (resulting in a malformed yaml + document), then discard changes. + + TODO: this test currently fails, on purpose. User gets into an endless + "fix?" -> "ok.fixed." prompt loop unless he is able to provide a + well-formed yaml.""" + # edit the yaml file to an invalid file + self.run_mocked_command({'contents': '!MALFORMED'}, + # edit? n, done? y, fix? n + ['y', 'y', 'n']) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + + def test_invalid_yaml_discard(self): + """Edit the yaml file incorrectly (resulting in a well-formed but + invalid yaml document), then discard changes. + + TODO: this test currently fails, on purpose. `check_diff()` chokes + ungracefully""" + # edit the yaml file to an invalid file + self.run_mocked_command({'contents': 'wellformed: yes, but invalid'}, + # edit? n, done? y + ['y', 'y']) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == b'__main__': + unittest.main(defaultTest='suite') From 6e6aa9700db6a6dee2ad1fa45a16a10e9cf360be Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Tue, 10 Nov 2015 19:16:04 +0100 Subject: [PATCH 29/93] edit: more assertions on existing tests, new tests * Modify existing tests in order to explicitely check for differences (or lack of) in the items fields, with the intention to ensure that no unintended changes slip through. * Added tests for modifying a single item from a list of items, and for editing the album field of the items (stub for discussing whether the actual album should be updated, etc). --- test/test_edit.py | 58 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/test/test_edit.py b/test/test_edit.py index 90048b96a..702830613 100644 --- a/test/test_edit.py +++ b/test/test_edit.py @@ -78,8 +78,12 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.load_plugins('edit') # make sure that we avoid invoking the editor except for making changes self.config['edit']['diff_method'] = '' - # add an album - self.add_album_fixture(track_count=self.TRACK_COUNT) + # add an album, storing the original fields for comparison + self.album = self.add_album_fixture(track_count=self.TRACK_COUNT) + self.album_orig = {f: self.album[f] for f in self.album._fields} + self.items_orig = [{f: item[f] for f in item._fields} for + item in self.album.items()] + # keep track of write()s library.Item.write = Mock() @@ -103,6 +107,20 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.assertTrue(all(i.title.startswith(title_starts_with) for i in self.lib.items())) + def assertItemFieldsModified(self, library_items, items, fields=[]): + """Assert that items in the library (`lib_items`) have different values + on the specified `fields` (and *only* on those fields), compared to + `items`. + An empty `fields` list results in asserting that no modifications have + been performed. + """ + changed_fields = [] + for lib_item, item in zip(library_items, items): + changed_fields.append([field for field in lib_item._fields + if lib_item[field] != item[field]]) + self.assertTrue(all(diff_fields == fields for diff_fields in + changed_fields)) + def test_title_edit_discard(self): """Edit title for all items in the library, then discard changes-""" # edit titles @@ -113,6 +131,7 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') + self.assertItemFieldsModified(self.album.items(), self.items_orig, []) def test_title_edit_apply(self): """Edit title for all items in the library, then apply changes.""" @@ -124,6 +143,23 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.assertCounts(write_call_count=self.TRACK_COUNT, title_starts_with=u'modified t\u00eftle') + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['title']) + + def test_single_title_edit_apply(self): + """Edit title for on items in the library, then apply changes.""" + # edit title + self.run_mocked_command({'replacements': {u't\u00eftle 9': + u'modified t\u00eftle 9'}}, + # edit? y, done? y, modify? y + ['y', 'y', 'y']) + + self.assertCounts(write_call_count=1,) + # no changes except on last item + self.assertItemFieldsModified(list(self.album.items())[:-1], + self.items_orig[:-1], []) + self.assertEqual(list(self.album.items())[-1].title, + u'modified t\u00eftle 9') def test_noedit(self): """Do not edit anything.""" @@ -134,6 +170,24 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') + self.assertItemFieldsModified(self.album.items(), self.items_orig, []) + + def test_album_edit_apply(self): + """Edit the album field for all items in the library, apply changes + + TODO: decide if the plugin should be wise enough to update the album, + and handle other complex cases (create new albums, etc). At the moment + this test only checks for modifications on items. + """ + # edit album + self.run_mocked_command({'replacements': {u'\u00e4lbum': + u'modified \u00e4lbum'}}, + # edit? y, done? y, modify? y + ['y', 'y', 'y']) + + self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['album']) def test_malformed_yaml_discard(self): """Edit the yaml file incorrectly (resulting in a malformed yaml From d9ebb3409a335dec386763c2f6c61adf70018ef7 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 11 Nov 2015 15:50:32 +0100 Subject: [PATCH 30/93] edit: add album query tests, revise failing ones * Add tests for executing the command with album queries (-a), testing the edit of album and albumartist fields. * Revise invalid and malformed yaml tests so they return a failure instead of an error. --- test/test_edit.py | 65 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/test/test_edit.py b/test/test_edit.py index 702830613..f853df6a3 100644 --- a/test/test_edit.py +++ b/test/test_edit.py @@ -22,6 +22,7 @@ from test.helper import TestHelper, control_stdin from beets import library from beetsplug.edit import EditPlugin +from beets.ui import UserError class ModifyFileMocker(object): @@ -91,12 +92,13 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.teardown_beets() self.unload_plugins() - def run_mocked_command(self, modify_file_args={}, stdin=[]): - """Run the edit command, with mocked stdin and yaml writing.""" + def run_mocked_command(self, modify_file_args={}, stdin=[], args=[]): + """Run the edit command, with mocked stdin and yaml writing, and + passing `args` to `run_command`.""" m = ModifyFileMocker(**modify_file_args) with patch.object(EditPlugin, 'get_editor', side_effect=m.action): with control_stdin('\n'.join(stdin)): - self.run_command('edit') + self.run_command('edit', *args) def assertCounts(self, album_count=ALBUM_COUNT, track_count=TRACK_COUNT, write_call_count=TRACK_COUNT, title_starts_with=''): @@ -173,11 +175,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.assertItemFieldsModified(self.album.items(), self.items_orig, []) def test_album_edit_apply(self): - """Edit the album field for all items in the library, apply changes - - TODO: decide if the plugin should be wise enough to update the album, - and handle other complex cases (create new albums, etc). At the moment - this test only checks for modifications on items. + """Edit the album field for all items in the library, apply changes. + By design, the album should not be updated."" """ # edit album self.run_mocked_command({'replacements': {u'\u00e4lbum': @@ -188,6 +187,37 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.assertCounts(write_call_count=self.TRACK_COUNT) self.assertItemFieldsModified(self.album.items(), self.items_orig, ['album']) + # ensure album is *not* modified + self.album.load() + self.assertEqual(self.album.album, u'\u00e4lbum') + + def test_a_album_edit_apply(self): + """Album query (-a), edit album field, apply changes.""" + self.run_mocked_command({'replacements': {u'\u00e4lbum': + u'modified \u00e4lbum'}}, + # edit? y, done? y, modify? y + ['y', 'y', 'y'], + args=['-a']) + + self.album.load() + self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertEqual(self.album.album, u'modified \u00e4lbum') + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['album']) + + def test_a_albumartist_edit_apply(self): + """Album query (-a), edit albumartist field, apply changes.""" + self.run_mocked_command({'replacements': {u'album artist': + u'modified album artist'}}, + # edit? y, done? y, modify? y + ['y', 'y', 'y'], + args=['-a']) + + self.album.load() + self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertEqual(self.album.albumartist, u'the modified album artist') + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['albumartist']) def test_malformed_yaml_discard(self): """Edit the yaml file incorrectly (resulting in a malformed yaml @@ -197,9 +227,12 @@ class EditCommandTest(unittest.TestCase, TestHelper): "fix?" -> "ok.fixed." prompt loop unless he is able to provide a well-formed yaml.""" # edit the yaml file to an invalid file - self.run_mocked_command({'contents': '!MALFORMED'}, - # edit? n, done? y, fix? n - ['y', 'y', 'n']) + try: + self.run_mocked_command({'contents': '!MALFORMED'}, + # edit? y, done? y, fix? n + ['y', 'y', 'n']) + except UserError as e: + self.fail(repr(e)) self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') @@ -211,9 +244,13 @@ class EditCommandTest(unittest.TestCase, TestHelper): TODO: this test currently fails, on purpose. `check_diff()` chokes ungracefully""" # edit the yaml file to an invalid file - self.run_mocked_command({'contents': 'wellformed: yes, but invalid'}, - # edit? n, done? y - ['y', 'y']) + try: + self.run_mocked_command({'contents': + 'wellformed: yes, but invalid'}, + # edit? y, done? y + ['y', 'y']) + except KeyError as e: + self.fail(repr(e)) self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') From 386578d69c1b92646397de2af3fed5d44b0c2811 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Fri, 13 Nov 2015 11:24:19 +0100 Subject: [PATCH 31/93] change opts.extra to type choice --- beetsplug/edit.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 62dcd8bdc..406cffc65 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -101,7 +101,10 @@ class EditPlugin(plugins.BeetsPlugin): help='interactively edit metadata') edit_command.parser.add_option( '-e', '--extra', - action='store', + action='append', + type='choice', + choices=library.Item.all_keys() + + library.Album.all_keys(), help='add additional fields to edit', ) edit_command.parser.add_option( @@ -188,8 +191,7 @@ class EditPlugin(plugins.BeetsPlugin): self.fields = self.albumfields if opts.album else self.itemfields # if opts.extra is given add those if opts.extra: - fi = (opts.extra).replace('$', "").split() - self.fields.extend([f for f in fi if f not in self.fields]) + self.fields.extend([f for f in opts.extra if f not in self.fields]) # make sure we got the id for identification if 'id' not in self.fields: self.fields.insert(0, 'id') From 024ab0159b1768b1bc8e3b3325713215d0106b9f Mon Sep 17 00:00:00 2001 From: jmwatte Date: Fri, 13 Nov 2015 15:03:27 +0100 Subject: [PATCH 32/93] remove webbrowser for opening default editor/browser --- beetsplug/edit.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 406cffc65..1d982a04c 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -12,8 +12,7 @@ # 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 change -them directly. +"""Open metadata information in a text editor to let the user edit it. """ from __future__ import (division, absolute_import, print_function, unicode_literals) @@ -25,13 +24,13 @@ import subprocess import difflib import yaml import collections -import webbrowser from contextlib import nested from sys import exit from beets import config from beets import ui from tempfile import NamedTemporaryFile import os +import sys class EditPlugin(plugins.BeetsPlugin): @@ -98,7 +97,8 @@ class EditPlugin(plugins.BeetsPlugin): def commands(self): edit_command = Subcommand( 'edit', - help='interactively edit metadata') + help='interactively edit metadata' + ) edit_command.parser.add_option( '-e', '--extra', action='append', @@ -286,14 +286,25 @@ class EditPlugin(plugins.BeetsPlugin): os.remove(new.name) exit() + def open_file(self, startcmd): + # opens a file in the standard program on all systems + subprocess.call(('cmd /c start "" "' + startcmd + '"') + if os.name is 'nt' else ( + 'open' if sys.platform.startswith('darwin') else + 'xdg-open', startcmd)) + def get_editor(self, name): if not self.ed: + # if not specified in config use $EDITOR from system editor = os.getenv('EDITOR') if editor: os.system(editor + " " + name) else: - webbrowser.open(name, new=2, autoraise=True) + # let the system handle the file + self.open_file(name) + # webbrowser.open(name, new=2, autoraise=True) else: + # use the editor specified in config callmethod = [self.ed] if self.ed_args: callmethod.extend(self.ed_args) @@ -416,17 +427,22 @@ class EditPlugin(plugins.BeetsPlugin): diff = difflib.HtmlDiff() df = diff.make_file(newlines, oldlines) - with NamedTemporaryFile('w', suffix='.html', bufsize=0) as ht: + with NamedTemporaryFile( + 'w', suffix='.html', bufsize=0, delete=False) as ht: # TODO: if webbrowser.open() is not blocking, ht might be deleted - # too soon - need to test + # too soon - need to test aded delete=false otherwise file is gone + # before the browser picks it up ht.write(df) if not self.brw: + # if browser not in config get $BROWSER browser = os.getenv('BROWSER') if browser: os.system(browser + " " + ht.name) else: - webbrowser.open(ht.name, new=2, autoraise=True) + # let the system handle it + self.open_file(ht.name) else: + # use browser specified in config callmethod = [self.brw] if self.brw_args: callmethod.extend(self.brw_args) From 5eca75321e27f4cab3a76e84ee70ff0a79b4ea0d Mon Sep 17 00:00:00 2001 From: jmwatte Date: Fri, 13 Nov 2015 17:56:03 +0100 Subject: [PATCH 33/93] simplify by removing sum-option and renaming for clarity --- beetsplug/edit.py | 179 ++++++++++++++-------------------------------- 1 file changed, 54 insertions(+), 125 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 1d982a04c..d39c1ca6b 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -39,18 +39,14 @@ class EditPlugin(plugins.BeetsPlugin): super(EditPlugin, self).__init__() self.config.add({ - 'style': 'yaml', 'editor': '', 'diff_method': '', 'browser': '', 'albumfields': 'album albumartist', 'itemfields': 'track title artist album', 'not_fields': 'id path', - 'sep': '-', }) - self.style = self.config['style'].get(unicode) - # The editor field in the config lets you specify your editor. # Defaults to open with webrowser module. self.editor = self.config['editor'].as_str_seq() @@ -85,10 +81,6 @@ class EditPlugin(plugins.BeetsPlugin): # value. The ID of an item will never be changed. self.not_fields = self.config['not_fields'].as_str_seq() - # the separator in your config sets the separator that will be used - # between fields in your terminal. Defaults to - - self.sep = self.config['sep'].get(unicode) - self.ed = None self.ed_args = None self.brw = None @@ -112,11 +104,6 @@ class EditPlugin(plugins.BeetsPlugin): action='store_true', dest='all', help='edit all fields', ) - edit_command.parser.add_option( - '--sum', - action='store_true', dest='sum', - help='list fields with the same value', - ) edit_command.parser.add_all_common_options() edit_command.func = self.editor_music return[edit_command] @@ -129,23 +116,6 @@ class EditPlugin(plugins.BeetsPlugin): self.brw_args = self.browser[1:] if len(self.browser) > 1 else None self.brw = self.browser[0] if self.browser else None - # Get the objects to edit. - query = decargs(args) - items, albums = _do_query(lib, query, opts.album, False) - objs = albums if opts.album else items - if not objs: - print_('Nothing to edit.') - return - - # edit tags in a textfile in yaml-style - # makes a string representation of an object - # for now yaml but we could add html,pprint,toml - self.print_items = { - 'yaml': self.print_to_yaml} - # makes an object from a string representation - # for now yaml but we could add html,pprint,toml - self.string_to_dict = { - 'yaml': self.yaml_to_dict} # 4 ways to view the changes in objects self.diffresults = { 'ndiff': self.ndiff, @@ -157,16 +127,24 @@ class EditPlugin(plugins.BeetsPlugin): self.make_dict = { 'all': self.get_all_fields, "selected": self.get_selected_fields} - + # main program flow + # Get the objects to edit. + query = decargs(args) + items, albums = _do_query(lib, query, opts.album, False) + objs = albums if opts.album else items + if not objs: + print_('Nothing to edit.') + return + # get the fields the user wants self.get_fields_from(objs, opts) - - # Confirm. + # Confirmation from user about the queryresult for obj in objs: print_(format(obj)) if not ui.input_yn(ui.colorize('action_default', "Edit? (y/n)"), True): return - + # get the fields from the objects dict_from_objs = self.make_dict[self.pick](self.fields, objs, opts) + # present the yaml to the user and let her change it newyaml, oldyaml = self.change_objs(dict_from_objs) changed_objs = self.check_diff(newyaml, oldyaml, opts) if not changed_objs: @@ -203,6 +181,7 @@ class EditPlugin(plugins.BeetsPlugin): else: for it in self.fields: if opts.album: + # check if it is really an albumfield if it not in library.Album.all_keys(): print_( "{} not in albumfields.Removed it.".format( @@ -210,6 +189,7 @@ class EditPlugin(plugins.BeetsPlugin): 'text_warning', it))) self.fields.remove(it) else: + # if it is not an itemfield remove it if it not in library.Item.all_keys(): print_( "{} not in itemfields.Removed it.".format( @@ -219,58 +199,34 @@ class EditPlugin(plugins.BeetsPlugin): self.pick = "selected" def get_selected_fields(self, myfields, objs, opts): - a = [] - if opts.sum: - for field in myfields: - if field not in self.not_fields: - d = collections.defaultdict(str) - for obj in objs: - d[obj[field]] += (" " + str(obj['id'])) - a.append([field, [{f: i} for f, i in d.items()]]) - return a - else: - for mod in objs: - a.append([{fi: mod[fi]}for fi in myfields]) - return a + return [[{field: obj[field]}for field in myfields]for obj in objs] def get_all_fields(self, myfields, objs, opts): - a = [] - if opts.sum: - fields = (library.Album.all_keys() if opts.album - else library.Item.all_keys()) - for field in sorted(fields): - # for every field get a dict - d = collections.defaultdict(str) - for obj in objs: - # put all the ob-ids in the dict[field] as a string - d[obj[field]] += (" " + str(obj['id'])) - # for the field, we get the value and the users - a.append([field, [{f: i} for f, i in sorted(d.items())]]) - return a - else: - for mod in objs: - a.append([{fi: mod[fi]} for fi in sorted(mod._fields)]) - return a + 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_items[self.style](dict_items) - newyaml = self.print_items[self.style](dict_items) + 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() self.get_editor(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 print_(ui.colorize('text_warning', "change this fault: {}".format(e))) print_("correct format for empty = - '' :") @@ -280,7 +236,7 @@ class EditPlugin(plugins.BeetsPlugin): 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) @@ -311,53 +267,48 @@ class EditPlugin(plugins.BeetsPlugin): callmethod.append(name) subprocess.check_call(callmethod) - def same_format(self, newset, opts): - alld = collections.defaultdict(dict) - if opts.sum: - for o in newset: - for so in o: - ids = set((so[1].values()[0].split())) - for id_ in ids: - alld[id_].update( - {so[0].items()[0][1]: so[1].items()[0][0]}) - else: - for o in newset: - for so in o: - alld[o[0].values()[0]].update(so) - return alld + 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) - no = self.same_format(newset, opts) - oo = self.same_format(oldset, opts) - ono = zip(oo.items(), no.items()) - nl = [] - ol = [] - changedob = [] - for o, n in ono: + 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 - ol.append((format(ob),) + o[1:]) - ob.update(n[1]) - nl.append((format(ob),) + n[1:]) - changedob.append(ob) + 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 if self.diff_method: - ostr = self.print_items[self.style](ol) - nwstr = self.print_items[self.style](nl) + ostr = self.print_to_yaml(oldSetTitled) + nwstr = self.print_to_yaml(newSetTitled) self.diffresults[self.diff_method](ostr, nwstr) else: - for obj in changedob: + for obj in changedObjs: ui.show_model_changes(obj) - self.save_write(changedob) + self.save_write(changedObjs) def save_write(self, changedob): - if not ui.input_yn(ui.colorize('action_default', - 'really modify? (y/n)')): + if not ui.input_yn( + ui.colorize('action_default', 'really modify? (y/n)')): return for ob in changedob: @@ -371,31 +322,9 @@ class EditPlugin(plugins.BeetsPlugin): def check_diff(self, newyaml, oldyaml, opts): # make python objs from yamlstrings - nl = self.string_to_dict[self.style](newyaml) - ol = self.string_to_dict[self.style](oldyaml) - if opts.sum: - return filter(None, map(self.reduce_sum, ol, nl)) - else: - return filter(None, map(self.reduce_it, ol, nl)) - - def reduce_sum(self, ol, nl): - # only get the changed objs - if ol != nl: - sol = [i for i in ol[1]] - snl = [i for i in nl[1]] - a = filter(None, map(self.reduce_sub, sol, snl)) - header = {"field": ol[0]} - ol = [[header, b[0]] for b in a] - nl = [[header, b[1]] for b in a] - return ol, nl - - def reduce_sub(self, sol, snl): - # if the keys have changed resets them - if sol != snl: - if snl.values() != sol.values(): - snl[snl.keys()[0]] = sol[sol.keys()[0]] - if sol != snl: - return sol, snl + 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 @@ -404,7 +333,7 @@ class EditPlugin(plugins.BeetsPlugin): if ol[x] != nl[x] and ol[x].keys()[0]in self.not_fields: nl[x] = ol[x] print_("reset forbidden field.") - if ol != nl: + if ol != nl: # only keep objects that have changed return ol, nl def ndiff(self, newfilestr, oldfilestr): From 71e1547291b63bf9d6fb01a0e5514f07c5fa2d46 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 12:50:53 -0800 Subject: [PATCH 34/93] Remove some communication through fields This is the first of many changes to reduce the use of `self.x` where plain parameter passing can make things more clear. --- beetsplug/edit.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index d39c1ca6b..6f7b7a419 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -106,7 +106,7 @@ class EditPlugin(plugins.BeetsPlugin): ) edit_command.parser.add_all_common_options() edit_command.func = self.editor_music - return[edit_command] + return [edit_command] def editor_music(self, lib, opts, args): if self.editor: @@ -122,11 +122,7 @@ class EditPlugin(plugins.BeetsPlugin): 'unified': self.unified, 'html': self.html, 'vimdiff': self.vimdiff} - # make a dictionary from the chosen fields - # you can do em all or a selection - self.make_dict = { - 'all': self.get_all_fields, - "selected": self.get_selected_fields} + # main program flow # Get the objects to edit. query = decargs(args) @@ -135,17 +131,21 @@ class EditPlugin(plugins.BeetsPlugin): if not objs: print_('Nothing to edit.') return - # get the fields the user wants - self.get_fields_from(objs, opts) # Confirmation from user about the queryresult for obj in objs: print_(format(obj)) if not ui.input_yn(ui.colorize('action_default', "Edit? (y/n)"), True): return + # get the fields from the objects - dict_from_objs = self.make_dict[self.pick](self.fields, objs, opts) + 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(dict_from_objs) + newyaml, oldyaml = self.change_objs(data) changed_objs = self.check_diff(newyaml, oldyaml, opts) if not changed_objs: print_("nothing to change") @@ -166,20 +166,19 @@ class EditPlugin(plugins.BeetsPlugin): def get_fields_from(self, objs, opts): # construct a list of fields we need # see if we need album or item fields - self.fields = self.albumfields if opts.album else self.itemfields + fields = self.albumfields if opts.album else self.itemfields # if opts.extra is given add those if opts.extra: - self.fields.extend([f for f in opts.extra if f not in self.fields]) + 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 self.fields: - self.fields.insert(0, 'id') + if 'id' not in fields: + fields.insert(0, 'id') # we need all the fields if opts.all: - self.fields = None - self.pick = "all" + fields = None print_(ui.colorize('text_warning', "edit all fields from:")) else: - for it in self.fields: + for it in fields: if opts.album: # check if it is really an albumfield if it not in library.Album.all_keys(): @@ -187,7 +186,7 @@ class EditPlugin(plugins.BeetsPlugin): "{} not in albumfields.Removed it.".format( ui.colorize( 'text_warning', it))) - self.fields.remove(it) + fields.remove(it) else: # if it is not an itemfield remove it if it not in library.Item.all_keys(): @@ -195,13 +194,14 @@ class EditPlugin(plugins.BeetsPlugin): "{} not in itemfields.Removed it.".format( ui.colorize( 'text_warning', it))) - self.fields.remove(it) - self.pick = "selected" + 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, myfields, objs, opts): + def get_all_fields(self, objs): return [[{field: obj[field]}for field in sorted(obj._fields)] for obj in objs] From d7d609442ec60d9fd0ed4892a489ed162a2b2328 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 12:53:45 -0800 Subject: [PATCH 35/93] Remove diff_method option Our built-in "diff"-like functionality is pretty good because it's aware of beets' data structures and types. This makes it more legible, in my opinion, than an ordinary textual diff. So for now, I'm making this the only option (in the spirit of making the plugin as straightforward as humanly possible). --- beetsplug/edit.py | 27 ++++----------------------- docs/plugins/edit.rst | 7 ------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 6f7b7a419..9ef37bd17 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -40,7 +40,6 @@ class EditPlugin(plugins.BeetsPlugin): self.config.add({ 'editor': '', - 'diff_method': '', 'browser': '', 'albumfields': 'album albumartist', 'itemfields': 'track title artist album', @@ -55,14 +54,6 @@ class EditPlugin(plugins.BeetsPlugin): # your htmlviewer. Defaults to open with webrowser module self.browser = self.config['browser'].as_str_seq() - # the diff_method field in your config picks the way to see your - # changes. Options are: - # 'ndiff'(2 files with differences), - # 'unified'(just the different lines and a few lines of context), - # 'html'(view in html-format), - # 'vimdiff'(view in VIM) - self.diff_method = self.config['diff_method'].get(unicode) - # the albumfields field in your config sets the tags that # you want to see/change for albums. # Defaults to album albumartist. @@ -116,13 +107,6 @@ class EditPlugin(plugins.BeetsPlugin): self.brw_args = self.browser[1:] if len(self.browser) > 1 else None self.brw = self.browser[0] if self.browser else None - # 4 ways to view the changes in objects - self.diffresults = { - 'ndiff': self.ndiff, - 'unified': self.unified, - 'html': self.html, - 'vimdiff': self.vimdiff} - # main program flow # Get the objects to edit. query = decargs(args) @@ -296,14 +280,11 @@ class EditPlugin(plugins.BeetsPlugin): ob.update(n[1]) # update the object newSetTitled.append((format(ob),) + n[1:]) changedObjs.append(ob) + # see the changes we made - if self.diff_method: - ostr = self.print_to_yaml(oldSetTitled) - nwstr = self.print_to_yaml(newSetTitled) - self.diffresults[self.diff_method](ostr, nwstr) - else: - for obj in changedObjs: - ui.show_model_changes(obj) + for obj in changedObjs: + ui.show_model_changes(obj) + self.save_write(changedObjs) def save_write(self, changedob): diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst index 3b48635c4..144d71b74 100644 --- a/docs/plugins/edit.rst +++ b/docs/plugins/edit.rst @@ -52,7 +52,6 @@ Make a ``edit:`` section in your config.yaml ``(beet config -e)`` edit: editor: nano -w -p - diff_method: html browser: firefox -private-window albumfields: genre album itemfields: track artist @@ -61,12 +60,6 @@ Make a ``edit:`` section in your config.yaml ``(beet config -e)`` * ``editor:`` pick your own texteditor; add arguments if needed. If no``editor:`` then your system opens the file-extension. -* ``diff_method:`` 4 choices. With no ``diff_method:`` you get the beets way of showing differences. - - ``ndiff``: you see original and the changed yamls with the changes. - - ``unified``: you see the changes with a bit of context. Simple and compact. - - ``html``: a html file that you can open in a browser. Looks nice. - - ``vimdiff``: gives you VIM with the diffs.You need VIM for this. - * ``browser:`` If you pick ``diff_method:html`` you can specify a viewer for it (if needed add arguments). If not, let your system open the file-extension. From e235cb7c42fd236a03aa4608d8c5db7de012fed5 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 12:56:31 -0800 Subject: [PATCH 36/93] Remove vestiges of diff_method option --- beetsplug/edit.py | 67 ------------------------------------------- docs/plugins/edit.rst | 6 +--- 2 files changed, 1 insertion(+), 72 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 9ef37bd17..f68523129 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -21,10 +21,8 @@ from beets import plugins from beets.ui import Subcommand, decargs, library, print_ from beets.ui.commands import _do_query import subprocess -import difflib import yaml import collections -from contextlib import nested from sys import exit from beets import config from beets import ui @@ -40,20 +38,14 @@ class EditPlugin(plugins.BeetsPlugin): self.config.add({ 'editor': '', - 'browser': '', 'albumfields': 'album albumartist', 'itemfields': 'track title artist album', 'not_fields': 'id path', }) # The editor field in the config lets you specify your editor. - # Defaults to open with webrowser module. self.editor = self.config['editor'].as_str_seq() - # the html_viewer field in your config lets you specify - # your htmlviewer. Defaults to open with webrowser module - self.browser = self.config['browser'].as_str_seq() - # the albumfields field in your config sets the tags that # you want to see/change for albums. # Defaults to album albumartist. @@ -74,8 +66,6 @@ class EditPlugin(plugins.BeetsPlugin): self.ed = None self.ed_args = None - self.brw = None - self.brw_args = None def commands(self): edit_command = Subcommand( @@ -103,9 +93,6 @@ class EditPlugin(plugins.BeetsPlugin): 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 - if self.browser: - self.brw_args = self.browser[1:] if len(self.browser) > 1 else None - self.brw = self.browser[0] if self.browser else None # main program flow # Get the objects to edit. @@ -242,7 +229,6 @@ class EditPlugin(plugins.BeetsPlugin): else: # let the system handle the file self.open_file(name) - # webbrowser.open(name, new=2, autoraise=True) else: # use the editor specified in config callmethod = [self.ed] @@ -316,56 +302,3 @@ class EditPlugin(plugins.BeetsPlugin): print_("reset forbidden field.") if ol != nl: # only keep objects that have changed return ol, nl - - def ndiff(self, newfilestr, oldfilestr): - newlines = newfilestr.splitlines() - oldlines = oldfilestr.splitlines() - diff = difflib.ndiff(newlines, oldlines) - print_('\n'.join(list(diff))) - return - - def unified(self, newfilestr, oldfilestr): - newlines = newfilestr.splitlines() - oldlines = oldfilestr.splitlines() - diff = difflib.unified_diff(newlines, oldlines, lineterm='') - print_('\n'.join(list(diff))) - return - - def html(self, newfilestr, oldfilestr): - newlines = newfilestr.splitlines() - oldlines = oldfilestr.splitlines() - diff = difflib.HtmlDiff() - df = diff.make_file(newlines, oldlines) - - with NamedTemporaryFile( - 'w', suffix='.html', bufsize=0, delete=False) as ht: - # TODO: if webbrowser.open() is not blocking, ht might be deleted - # too soon - need to test aded delete=false otherwise file is gone - # before the browser picks it up - ht.write(df) - if not self.brw: - # if browser not in config get $BROWSER - browser = os.getenv('BROWSER') - if browser: - os.system(browser + " " + ht.name) - else: - # let the system handle it - self.open_file(ht.name) - else: - # use browser specified in config - callmethod = [self.brw] - if self.brw_args: - callmethod.extend(self.brw_args) - callmethod.append(ht.name) - subprocess.call(callmethod) - - return - - def vimdiff(self, newstringstr, oldstringstr): - with nested(NamedTemporaryFile(suffix='.old.yaml', - bufsize=0), - NamedTemporaryFile(suffix='.new.yaml', - bufsize=0)) as (newdiff, olddiff): - newdiff.write(newstringstr) - olddiff.write(oldstringstr) - subprocess.call(['vimdiff', newdiff.name, olddiff.name]) diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst index 144d71b74..85d7343fc 100644 --- a/docs/plugins/edit.rst +++ b/docs/plugins/edit.rst @@ -52,7 +52,6 @@ Make a ``edit:`` section in your config.yaml ``(beet config -e)`` edit: editor: nano -w -p - browser: firefox -private-window albumfields: genre album itemfields: track artist not_fields: id path @@ -60,9 +59,6 @@ Make a ``edit:`` section in your config.yaml ``(beet config -e)`` * ``editor:`` pick your own texteditor; add arguments if needed. If no``editor:`` then your system opens the file-extension. -* ``browser:`` - If you pick ``diff_method:html`` you can specify a viewer for it (if needed add arguments). If not, let your system open the file-extension. - * The ``albumfields:`` and ``itemfields:`` lets you list the fields you want to change. ``albumfields:`` gets picked if you put ``-a`` in your search query, else ``itemfields:``. For a list of fields do the ``beet fields`` command. @@ -76,7 +72,7 @@ Make a ``edit:`` section in your config.yaml ``(beet config -e)`` -02-The Night Before-The Beatles-Help! - + but you can pick anything else. With "<>" it will look like: :: From 2d8350ef030e5f9267768124f29b0d7c11f0ffce Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 13:31:42 -0800 Subject: [PATCH 37/93] Use standard machinery for opening editor --- beetsplug/edit.py | 41 +++++++++++++---------------------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index f68523129..63763b7d9 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -18,7 +18,9 @@ from __future__ import (division, absolute_import, print_function, unicode_literals) from beets import plugins -from beets.ui import Subcommand, decargs, library, print_ +from beets import util +from beets import library +from beets.ui import Subcommand, decargs, print_ from beets.ui.commands import _do_query import subprocess import yaml @@ -28,7 +30,14 @@ from beets import config from beets import ui from tempfile import NamedTemporaryFile import os -import sys + + +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): @@ -184,7 +193,7 @@ class EditPlugin(plugins.BeetsPlugin): new = NamedTemporaryFile(suffix='.yaml', delete=False) new.write(newyaml) new.close() - self.get_editor(new.name) + edit(new.name) # wait for user to edit yaml and continue if ui.input_yn(ui.colorize('action_default', "done?(y)"), True): while True: @@ -203,7 +212,7 @@ class EditPlugin(plugins.BeetsPlugin): print_("correct format for empty = - '' :") if ui.input_yn( ui.colorize('action_default', "fix?(y)"), True): - self.get_editor(new.name) + edit(new.name) if ui.input_yn(ui.colorize( 'action_default', "ok.fixed.(y)"), True): pass @@ -213,30 +222,6 @@ class EditPlugin(plugins.BeetsPlugin): os.remove(new.name) exit() - def open_file(self, startcmd): - # opens a file in the standard program on all systems - subprocess.call(('cmd /c start "" "' + startcmd + '"') - if os.name is 'nt' else ( - 'open' if sys.platform.startswith('darwin') else - 'xdg-open', startcmd)) - - def get_editor(self, name): - if not self.ed: - # if not specified in config use $EDITOR from system - editor = os.getenv('EDITOR') - if editor: - os.system(editor + " " + name) - else: - # let the system handle the file - self.open_file(name) - else: - # use the editor specified in config - callmethod = [self.ed] - if self.ed_args: - callmethod.extend(self.ed_args) - callmethod.append(name) - subprocess.check_call(callmethod) - 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 From b593d01100499fcfb214afb675e5089551d3aad9 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 13:33:02 -0800 Subject: [PATCH 38/93] Use qualified names for `ui` utilities --- beetsplug/edit.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 63763b7d9..6fb9be732 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -20,7 +20,7 @@ from __future__ import (division, absolute_import, print_function, from beets import plugins from beets import util from beets import library -from beets.ui import Subcommand, decargs, print_ +from beets import ui from beets.ui.commands import _do_query import subprocess import yaml @@ -77,7 +77,7 @@ class EditPlugin(plugins.BeetsPlugin): self.ed_args = None def commands(self): - edit_command = Subcommand( + edit_command = ui.Subcommand( 'edit', help='interactively edit metadata' ) @@ -105,15 +105,15 @@ class EditPlugin(plugins.BeetsPlugin): # main program flow # Get the objects to edit. - query = decargs(args) + query = ui.decargs(args) items, albums = _do_query(lib, query, opts.album, False) objs = albums if opts.album else items if not objs: - print_('Nothing to edit.') + ui.print_('Nothing to edit.') return # Confirmation from user about the queryresult for obj in objs: - print_(format(obj)) + ui.print_(format(obj)) if not ui.input_yn(ui.colorize('action_default', "Edit? (y/n)"), True): return @@ -128,7 +128,7 @@ class EditPlugin(plugins.BeetsPlugin): newyaml, oldyaml = self.change_objs(data) changed_objs = self.check_diff(newyaml, oldyaml, opts) if not changed_objs: - print_("nothing to change") + ui.print_("nothing to change") return self.save_items(changed_objs, lib, opts) @@ -156,13 +156,13 @@ class EditPlugin(plugins.BeetsPlugin): # we need all the fields if opts.all: fields = None - print_(ui.colorize('text_warning', "edit all fields from:")) + 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(): - print_( + ui.print_( "{} not in albumfields.Removed it.".format( ui.colorize( 'text_warning', it))) @@ -170,7 +170,7 @@ class EditPlugin(plugins.BeetsPlugin): else: # if it is not an itemfield remove it if it not in library.Item.all_keys(): - print_( + ui.print_( "{} not in itemfields.Removed it.".format( ui.colorize( 'text_warning', it))) @@ -207,9 +207,9 @@ class EditPlugin(plugins.BeetsPlugin): except yaml.YAMLError as e: # some error-correcting mainly for empty-values # not being well-formated - print_(ui.colorize('text_warning', + ui.print_(ui.colorize('text_warning', "change this fault: {}".format(e))) - print_("correct format for empty = - '' :") + ui.print_("correct format for empty = - '' :") if ui.input_yn( ui.colorize('action_default', "fix?(y)"), True): edit(new.name) @@ -284,6 +284,6 @@ class EditPlugin(plugins.BeetsPlugin): 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] - print_("reset forbidden field.") + ui.print_("reset forbidden field.") if ol != nl: # only keep objects that have changed return ol, nl From b33a024549dd8e37991024e37e29d9ddefab46ae Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 13:37:17 -0800 Subject: [PATCH 39/93] Simplify write logic --- beetsplug/edit.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 6fb9be732..6d36e1fa3 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -26,8 +26,6 @@ import subprocess import yaml import collections from sys import exit -from beets import config -from beets import ui from tempfile import NamedTemporaryFile import os @@ -208,7 +206,7 @@ class EditPlugin(plugins.BeetsPlugin): # some error-correcting mainly for empty-values # not being well-formated ui.print_(ui.colorize('text_warning', - "change this fault: {}".format(e))) + "change this fault: {}".format(e))) ui.print_("correct format for empty = - '' :") if ui.input_yn( ui.colorize('action_default', "fix?(y)"), True): @@ -264,11 +262,8 @@ class EditPlugin(plugins.BeetsPlugin): return for ob in changedob: - if config['import']['write'].get(bool): - ob.try_sync() - else: - ob.store() - print("changed: {0}".format(ob)) + self._log.debug('saving changes to {}', ob) + ob.try_sync(ui.should_write()) return From de6813eab5dc31a64feadbc105a9dc506bcec43f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 13:38:41 -0800 Subject: [PATCH 40/93] Remove vestigial `editor` config option We now just use $EDITOR. --- beetsplug/edit.py | 11 ----------- docs/plugins/edit.rst | 3 --- 2 files changed, 14 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 6d36e1fa3..4c4d7e1ca 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -44,15 +44,11 @@ class EditPlugin(plugins.BeetsPlugin): 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. @@ -71,9 +67,6 @@ class EditPlugin(plugins.BeetsPlugin): # 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', @@ -97,10 +90,6 @@ class EditPlugin(plugins.BeetsPlugin): 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) diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst index 85d7343fc..224988fc5 100644 --- a/docs/plugins/edit.rst +++ b/docs/plugins/edit.rst @@ -51,14 +51,11 @@ Make a ``edit:`` section in your config.yaml ``(beet config -e)`` :: edit: - editor: nano -w -p albumfields: genre album itemfields: track artist not_fields: id path separator: "<>" -* ``editor:`` pick your own texteditor; add arguments if needed. If no``editor:`` then your system opens the file-extension. - * The ``albumfields:`` and ``itemfields:`` lets you list the fields you want to change. ``albumfields:`` gets picked if you put ``-a`` in your search query, else ``itemfields:``. For a list of fields do the ``beet fields`` command. From f27c4863895916a020b20824b02b047f3a0800b0 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 13:39:30 -0800 Subject: [PATCH 41/93] Remove `separator` and `not_fields` from docs The `separator` option has already been removed. `not_fields` is an internal detail and doesn't need to be documented (yet). --- docs/plugins/edit.rst | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst index 224988fc5..45dd042e7 100644 --- a/docs/plugins/edit.rst +++ b/docs/plugins/edit.rst @@ -53,22 +53,11 @@ Make a ``edit:`` section in your config.yaml ``(beet config -e)`` edit: albumfields: genre album itemfields: track artist - not_fields: id path - separator: "<>" * The ``albumfields:`` and ``itemfields:`` lets you list the fields you want to change. ``albumfields:`` gets picked if you put ``-a`` in your search query, else ``itemfields:``. For a list of fields do the ``beet fields`` command. -* The ``not_fields:``. Fields that you put in here will not be changed. You can see them but not change them. It always contains ``id`` and standard also the ``path``. - Don't want to mess with them. - -* The default ``separator:`` prints like: - -:: - - -02-The Night Before-The Beatles-Help! - but you can pick anything else. With "<>" it will look like: :: From ce31c2df233f45526b3c9c14b2b28ed14a297ed7 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 13:43:58 -0800 Subject: [PATCH 42/93] Remove some dead code and hoist utility functions --- beetsplug/edit.py | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 4c4d7e1ca..41f4606ae 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -38,6 +38,22 @@ def edit(filename): subprocess.call(cmd) +def dump(self, arg): + """Dump an object as YAML for editing. + """ + return yaml.safe_dump_all( + arg, + allow_unicode=True, + default_flow_style=False, + ) + + +def load(self, yam): + """Read a YAML string back to an object. + """ + return yaml.load_all(yam) + + class EditPlugin(plugins.BeetsPlugin): def __init__(self): @@ -119,17 +135,6 @@ class EditPlugin(plugins.BeetsPlugin): 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 @@ -175,8 +180,8 @@ class EditPlugin(plugins.BeetsPlugin): 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 + oldyaml = dump(dict_items) # our backup + newyaml = dump(dict_items) # goes to user new = NamedTemporaryFile(suffix='.yaml', delete=False) new.write(newyaml) new.close() @@ -225,8 +230,6 @@ class EditPlugin(plugins.BeetsPlugin): 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: @@ -234,9 +237,7 @@ class EditPlugin(plugins.BeetsPlugin): 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 @@ -258,8 +259,8 @@ class EditPlugin(plugins.BeetsPlugin): def check_diff(self, newyaml, oldyaml, opts): # make python objs from yamlstrings - nl = self.yaml_to_dict(newyaml) - ol = self.yaml_to_dict(oldyaml) + nl = load(newyaml) + ol = load(oldyaml) return filter(None, map(self.reduce_it, ol, nl)) def reduce_it(self, ol, nl): From 3c01c49a2c5f035ff8f017fb9b18737583ed1828 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 13:53:25 -0800 Subject: [PATCH 43/93] Less chatty interface Removed three prompts: 1. The "really edit?" prompt. If you don't want to edit, you can just not make any changes. 2. The "done?" loop. This seems unnecessary; we'll confirm afterward anyway. 3. The YAML checker. This removal could indeed make things inconvenient, since your changes get thrown away if you make a YAML mistake. For the moment, simplicity is taking priority. --- beetsplug/edit.py | 78 +++++++++++++++-------------------------------- 1 file changed, 25 insertions(+), 53 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 41f4606ae..27023ad7c 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -25,7 +25,6 @@ from beets.ui.commands import _do_query import subprocess import yaml import collections -from sys import exit from tempfile import NamedTemporaryFile import os @@ -38,7 +37,7 @@ def edit(filename): subprocess.call(cmd) -def dump(self, arg): +def dump(arg): """Dump an object as YAML for editing. """ return yaml.safe_dump_all( @@ -48,10 +47,10 @@ def dump(self, arg): ) -def load(self, yam): +def load(s): """Read a YAML string back to an object. """ - return yaml.load_all(yam) + return yaml.load_all(s) class EditPlugin(plugins.BeetsPlugin): @@ -106,7 +105,6 @@ class EditPlugin(plugins.BeetsPlugin): return [edit_command] def editor_music(self, lib, opts, args): - # main program flow # Get the objects to edit. query = ui.decargs(args) items, albums = _do_query(lib, query, opts.album, False) @@ -114,25 +112,22 @@ class EditPlugin(plugins.BeetsPlugin): 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 + # Get the content to edit as raw data structures. 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") + # Present the YAML to the user and let her change it. + new_data = self.change_objs(data) + changed_objs = self.check_diff(data, new_data) + if changed_objs is None: + # Editing failed. return + + # Save the new data. self.save_items(changed_objs, lib, opts) def get_fields_from(self, objs, opts): @@ -178,41 +173,21 @@ class EditPlugin(plugins.BeetsPlugin): 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 = dump(dict_items) # our backup - newyaml = dump(dict_items) # goes to user + # Ask the user to edit the data. new = NamedTemporaryFile(suffix='.yaml', delete=False) - new.write(newyaml) + new.write(dump(dict_items)) 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() + + # Parse the updated data. + with open(new.name) as f: + new_str = f.read() + os.remove(new.name) + try: + return load(new_str) + except yaml.YAMLError as e: + ui.print_("Invalid YAML: {}".format(e)) + return None def nice_format(self, newset): # format the results so that we have an ID at the top @@ -257,11 +232,8 @@ class EditPlugin(plugins.BeetsPlugin): return - def check_diff(self, newyaml, oldyaml, opts): - # make python objs from yamlstrings - nl = load(newyaml) - ol = load(oldyaml) - return filter(None, map(self.reduce_it, ol, nl)) + def check_diff(self, old_data, new_data): + return filter(None, map(self.reduce_it, old_data, new_data)) def reduce_it(self, ol, nl): # if there is a forbidden field it resets them From 105cd73906e7a85a2c8416a9827a8b682e9a9c5f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 14:05:40 -0800 Subject: [PATCH 44/93] Start expunging `opts` carry-through A dedicated command function takes care of the options and turns them into normal variables. --- beetsplug/edit.py | 89 ++++++++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 36 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 27023ad7c..041fe0f4e 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -101,10 +101,12 @@ class EditPlugin(plugins.BeetsPlugin): help='edit all fields', ) edit_command.parser.add_all_common_options() - edit_command.func = self.editor_music + edit_command.func = self._edit_command return [edit_command] - def editor_music(self, lib, opts, args): + def _edit_command(self, lib, opts, args): + """The CLI command function for the `beet edit` command. + """ # Get the objects to edit. query = ui.decargs(args) items, albums = _do_query(lib, query, opts.album, False) @@ -113,12 +115,29 @@ class EditPlugin(plugins.BeetsPlugin): ui.print_('Nothing to edit.') return - # Get the content to edit as raw data structures. + # Get the fields to edit. if opts.all: - data = self.get_all_fields(objs) + fields = None else: - fields = self.get_fields_from(objs, opts) - data = self.get_selected_fields(fields, objs, opts) + fields = self.get_fields_from(objs, opts.album, opts.extra) + # TODO + # fields.extend([f for f in opts.extra if f not in fields]) + + self.edit(lib, opts.album, objs, fields) + + def edit(self, lib, album, objs, fields): + """The core editor logic. + + - `lib`: The `Library` object. + - `album`: A flag indicating whether we're editing Items or Albums. + - `objs`: The `Item`s or `Album`s to edit. + - `fields`: The set of field names to edit. + """ + # Get the content to edit as raw data structures. + if fields: + data = self.get_selected_fields(fields, objs) + else: + data = self.get_all_fields(objs) # Present the YAML to the user and let her change it. new_data = self.change_objs(data) @@ -128,44 +147,42 @@ class EditPlugin(plugins.BeetsPlugin): return # Save the new data. - self.save_items(changed_objs, lib, opts) + self.save_items(changed_objs, lib, album) - def get_fields_from(self, objs, opts): + def get_fields_from(self, objs, album, extra): # construct a list of fields we need # see if we need album or item fields - fields = self.albumfields if opts.album else self.itemfields + fields = self.albumfields if 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]) + if extra: + fields.extend([f for f in 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) + + for it in fields: + if 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): + def get_selected_fields(self, myfields, objs): return [[{field: obj[field]}for field in myfields]for obj in objs] def get_all_fields(self, objs): @@ -199,7 +216,7 @@ class EditPlugin(plugins.BeetsPlugin): wellformed[item[0].values()[0]].update(field) return wellformed - def save_items(self, oldnewlist, lib, opts): + def save_items(self, oldnewlist, lib, album): oldset, newset = zip(*oldnewlist) niceNewSet = self.nice_format(newset) @@ -207,7 +224,7 @@ class EditPlugin(plugins.BeetsPlugin): niceCombiSet = zip(niceOldSet.items(), niceNewSet.items()) changedObjs = [] for o, n in niceCombiSet: - if opts.album: + if album: ob = lib.get_album(int(n[0])) else: ob = lib.get_item(n[0]) From 9848b51008c823b85649e0c696d30dbb0da1bc49 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 14:19:09 -0800 Subject: [PATCH 45/93] Simplify field selection --- beetsplug/edit.py | 71 ++++++++++++++--------------------------------- 1 file changed, 21 insertions(+), 50 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 041fe0f4e..bd41eb573 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -64,18 +64,6 @@ class EditPlugin(plugins.BeetsPlugin): 'not_fields': 'id path', }) - # 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 @@ -119,19 +107,35 @@ class EditPlugin(plugins.BeetsPlugin): if opts.all: fields = None else: - fields = self.get_fields_from(objs, opts.album, opts.extra) - # TODO - # fields.extend([f for f in opts.extra if f not in fields]) - + fields = self._get_fields(opts.album, opts.extra) self.edit(lib, opts.album, objs, fields) + def _get_fields(self, album, extra): + """Get the set of fields to edit. + """ + # Start with the configured base fields. + if album: + fields = self.config['albumfields'].as_str_seq() + else: + fields = self.config['itemfields'].as_str_seq() + + # Add the requested extra fields. + if extra: + fields += extra + + # Ensure we always have the `id` field for identification. + fields.append('id') + + return set(fields) + def edit(self, lib, album, objs, fields): """The core editor logic. - `lib`: The `Library` object. - `album`: A flag indicating whether we're editing Items or Albums. - `objs`: The `Item`s or `Album`s to edit. - - `fields`: The set of field names to edit. + - `fields`: The set of field names to edit (or None to edit + everything). """ # Get the content to edit as raw data structures. if fields: @@ -149,39 +153,6 @@ class EditPlugin(plugins.BeetsPlugin): # Save the new data. self.save_items(changed_objs, lib, album) - def get_fields_from(self, objs, album, extra): - # construct a list of fields we need - # see if we need album or item fields - fields = self.albumfields if album else self.itemfields - - # if opts.extra is given add those - if extra: - fields.extend([f for f in extra if f not in fields]) - - # make sure we got the id for identification - if 'id' not in fields: - fields.insert(0, 'id') - - for it in fields: - if 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): return [[{field: obj[field]}for field in myfields]for obj in objs] From 5096653483ede2f0a9537d2ffa1bdd092399b1ff Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 14:23:57 -0800 Subject: [PATCH 46/93] Simpler serialization Just use a dictionary instead of a list of tiny one-key dictionaries. Still need to update the deserialization. --- beetsplug/edit.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index bd41eb573..4a15df696 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -53,6 +53,18 @@ def load(s): return yaml.load_all(s) +def flatten(obj, fields): + """Represent `obj`, a `dbcore.Model` object, as a dictionary for + serialization. Only include the given `fields` if provided; + otherwise, include everything. + """ + d = dict(obj) + if fields: + return {k: v for k, v in d.items() if k in fields} + else: + return d + + class EditPlugin(plugins.BeetsPlugin): def __init__(self): @@ -138,13 +150,10 @@ class EditPlugin(plugins.BeetsPlugin): everything). """ # Get the content to edit as raw data structures. - if fields: - data = self.get_selected_fields(fields, objs) - else: - data = self.get_all_fields(objs) + data = [flatten(o, fields) for o in objs] # Present the YAML to the user and let her change it. - new_data = self.change_objs(data) + new_data = self.edit_data(data) changed_objs = self.check_diff(data, new_data) if changed_objs is None: # Editing failed. @@ -153,14 +162,7 @@ class EditPlugin(plugins.BeetsPlugin): # Save the new data. self.save_items(changed_objs, lib, album) - def get_selected_fields(self, myfields, objs): - 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): + def edit_data(self, dict_items): # Ask the user to edit the data. new = NamedTemporaryFile(suffix='.yaml', delete=False) new.write(dump(dict_items)) From 029915814b16e30793690f434e230a904d4f5905 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 14:28:00 -0800 Subject: [PATCH 47/93] Abort if nothing changed --- beetsplug/edit.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 4a15df696..2827cbfed 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -154,25 +154,39 @@ class EditPlugin(plugins.BeetsPlugin): # Present the YAML to the user and let her change it. new_data = self.edit_data(data) - changed_objs = self.check_diff(data, new_data) - if changed_objs is None: + if new_data is None: # Editing failed. return + changed_objs = self.check_diff(data, new_data) + # Save the new data. self.save_items(changed_objs, lib, album) - def edit_data(self, dict_items): + def edit_data(self, data): + """Dump a data structure to a file as text, ask the user to edit + it, and then read back the updated data. + + If something goes wrong during editing, return None to indicate + the process should abort. + """ # Ask the user to edit the data. new = NamedTemporaryFile(suffix='.yaml', delete=False) - new.write(dump(dict_items)) + old_str = dump(data) + new.write(old_str) new.close() edit(new.name) - # Parse the updated data. + # Read the data back after editing and check whether anything + # changed. with open(new.name) as f: new_str = f.read() os.remove(new.name) + if new_str == old_str: + ui.print_("No changes; aborting.") + return None + + # Parse the updated data. try: return load(new_str) except yaml.YAMLError as e: From 58205e1befbb70b391e6981fc222469fbb3eb97c Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 14:33:04 -0800 Subject: [PATCH 48/93] Simplify data filtering --- beetsplug/edit.py | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 2827cbfed..2b2da91b2 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -71,16 +71,13 @@ class EditPlugin(plugins.BeetsPlugin): super(EditPlugin, self).__init__() self.config.add({ + # The default fields to edit. 'albumfields': 'album albumartist', 'itemfields': 'track title artist album', - 'not_fields': 'id path', - }) - # 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() + # Silently ignore any changes to these fields. + 'ignore_fields': 'id path', + }) def commands(self): edit_command = ui.Subcommand( @@ -158,10 +155,16 @@ class EditPlugin(plugins.BeetsPlugin): # Editing failed. return - changed_objs = self.check_diff(data, new_data) + # Filter out any forbidden fields so we can avoid clobbering + # `id` and such by mistake. + ignore_fields = self.config['ignore_fields'].as_str_seq() + for d in data: + for key in list(d): + if key in ignore_fields: + del d[key] # Save the new data. - self.save_items(changed_objs, lib, album) + self.save_items(data, lib, album) def edit_data(self, data): """Dump a data structure to a file as text, ask the user to edit @@ -235,16 +238,3 @@ class EditPlugin(plugins.BeetsPlugin): ob.try_sync(ui.should_write()) return - - def check_diff(self, old_data, new_data): - return filter(None, map(self.reduce_it, old_data, new_data)) - - 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 From eb1bb132b1d276265afcc4d0109d48c013c12af1 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 14:48:10 -0800 Subject: [PATCH 49/93] Simplify metadata application --- beetsplug/edit.py | 67 ++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 2b2da91b2..481b33f62 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -24,7 +24,6 @@ from beets import ui from beets.ui.commands import _do_query import subprocess import yaml -import collections from tempfile import NamedTemporaryFile import os @@ -155,16 +154,11 @@ class EditPlugin(plugins.BeetsPlugin): # Editing failed. return - # Filter out any forbidden fields so we can avoid clobbering - # `id` and such by mistake. - ignore_fields = self.config['ignore_fields'].as_str_seq() - for d in data: - for key in list(d): - if key in ignore_fields: - del d[key] + # Apply the updated metadata to the objects. + self.apply_data(objs, new_data) # Save the new data. - self.save_items(data, lib, album) + self.save_write(objs) def edit_data(self, data): """Dump a data structure to a file as text, ask the user to edit @@ -196,39 +190,40 @@ class EditPlugin(plugins.BeetsPlugin): ui.print_("Invalid YAML: {}".format(e)) return None - 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 apply_data(self, objs, new_data): + """Take potentially-updated data and apply it to a set of Model + objects. - def save_items(self, oldnewlist, lib, album): + The objects are not written back to the database, so the changes + are temporary. + """ + obj_by_id = {o.id: o for o in objs} + ignore_fields = self.config['ignore_fields'].as_str_seq() + for d in new_data: + id = d.get('id') - oldset, newset = zip(*oldnewlist) - niceNewSet = self.nice_format(newset) - niceOldSet = self.nice_format(oldset) - niceCombiSet = zip(niceOldSet.items(), niceNewSet.items()) - changedObjs = [] - for o, n in niceCombiSet: - if album: - ob = lib.get_album(int(n[0])) - else: - ob = lib.get_item(n[0]) - # change id to item-string - ob.update(n[1]) # update the object - changedObjs.append(ob) + if not isinstance(id, int): + self._log.warn('skipping data with missing ID') + continue + if id not in obj_by_id: + self._log.warn('skipping unmatched ID {}', id) + continue - # see the changes we made - for obj in changedObjs: - ui.show_model_changes(obj) + obj = obj_by_id[d['id']] - self.save_write(changedObjs) + # Filter out any forbidden fields so we can avoid + # clobbering `id` and such by mistake. + for key in list(d): + if key in ignore_fields: + del d[key] + + obj.update(d) def save_write(self, changedob): + # see the changes we made + for obj in changedob: + ui.show_model_changes(obj) + if not ui.input_yn( ui.colorize('action_default', 'really modify? (y/n)')): return From 1c44969255ce8f4ec1772fa3515e9e63e08787a4 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 14:50:24 -0800 Subject: [PATCH 50/93] Simplify interfaces and add docs --- beetsplug/edit.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 481b33f62..43f56e94e 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -116,7 +116,7 @@ class EditPlugin(plugins.BeetsPlugin): fields = None else: fields = self._get_fields(opts.album, opts.extra) - self.edit(lib, opts.album, objs, fields) + self.edit(opts.album, objs, fields) def _get_fields(self, album, extra): """Get the set of fields to edit. @@ -136,10 +136,9 @@ class EditPlugin(plugins.BeetsPlugin): return set(fields) - def edit(self, lib, album, objs, fields): + def edit(self, album, objs, fields): """The core editor logic. - - `lib`: The `Library` object. - `album`: A flag indicating whether we're editing Items or Albums. - `objs`: The `Item`s or `Album`s to edit. - `fields`: The set of field names to edit (or None to edit @@ -219,17 +218,16 @@ class EditPlugin(plugins.BeetsPlugin): obj.update(d) - def save_write(self, changedob): - # see the changes we made - for obj in changedob: + def save_write(self, objs): + """Save a list of updated Model objects to the database. + """ + # Display and confirm the changes. + for obj in objs: ui.show_model_changes(obj) - - if not ui.input_yn( - ui.colorize('action_default', 'really modify? (y/n)')): + if not ui.input_yn('Apply changes? (y/n)'): return - for ob in changedob: + # Save to the database and possibly write tags. + for ob in objs: self._log.debug('saving changes to {}', ob) ob.try_sync(ui.should_write()) - - return From 80facbab6fe2ebda8191a2f6600a3a4c2bb292f0 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 14:57:32 -0800 Subject: [PATCH 51/93] More robust forbidden-change detection --- beetsplug/edit.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 43f56e94e..5cbf19ea2 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -49,7 +49,7 @@ def dump(arg): def load(s): """Read a YAML string back to an object. """ - return yaml.load_all(s) + return list(yaml.load_all(s)) def flatten(obj, fields): @@ -154,7 +154,7 @@ class EditPlugin(plugins.BeetsPlugin): return # Apply the updated metadata to the objects. - self.apply_data(objs, new_data) + self.apply_data(objs, data, new_data) # Save the new data. self.save_write(objs) @@ -189,34 +189,33 @@ class EditPlugin(plugins.BeetsPlugin): ui.print_("Invalid YAML: {}".format(e)) return None - def apply_data(self, objs, new_data): + def apply_data(self, objs, old_data, new_data): """Take potentially-updated data and apply it to a set of Model objects. The objects are not written back to the database, so the changes are temporary. """ + if len(old_data) != len(new_data): + self._log.warn('number of objects changed from {} to {}', + len(old_data), len(new_data)) + obj_by_id = {o.id: o for o in objs} ignore_fields = self.config['ignore_fields'].as_str_seq() - for d in new_data: - id = d.get('id') - - if not isinstance(id, int): - self._log.warn('skipping data with missing ID') - continue - if id not in obj_by_id: - self._log.warn('skipping unmatched ID {}', id) - continue - - obj = obj_by_id[d['id']] - - # Filter out any forbidden fields so we can avoid + for old_dict, new_dict in zip(old_data, new_data): + # Prohibit any changes to forbidden fields to avoid # clobbering `id` and such by mistake. - for key in list(d): - if key in ignore_fields: - del d[key] + forbidden = False + for key in ignore_fields: + if old_dict.get(key) != new_dict.get(key): + self._log.warn('ignoring object where {} changed', key) + forbidden = True + break + if forbidden: + continue - obj.update(d) + obj = obj_by_id[old_dict['id']] + obj.update(new_dict) def save_write(self, objs): """Save a list of updated Model objects to the database. From cfba04bc9d1c9f9acf723aa99e60319ae0aa2b18 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 14 Nov 2015 14:58:53 -0800 Subject: [PATCH 52/93] Don't ask for confirmation if nothing changed --- beetsplug/edit.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 5cbf19ea2..3a22d014e 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -221,8 +221,12 @@ class EditPlugin(plugins.BeetsPlugin): """Save a list of updated Model objects to the database. """ # Display and confirm the changes. + changed = False for obj in objs: - ui.show_model_changes(obj) + changed |= ui.show_model_changes(obj) + if not changed: + ui.print_('No changes to apply.') + return if not ui.input_yn('Apply changes? (y/n)'): return From 5f2e5d73ccb50f8a99a92f025344f9c75d79595a Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Sun, 15 Nov 2015 18:09:20 +0100 Subject: [PATCH 53/93] edit: update unit tests * Update unit tests in order to reflect the changes on the last refactor of edit.py (patch edit.edit instead of EditPlugin.get_editor, revise stdin strings to match current version, remove TODO on docstrings from malformed and invalid yaml tests). --- test/test_edit.py | 68 ++++++++++++++++++----------------------------- 1 file changed, 26 insertions(+), 42 deletions(-) diff --git a/test/test_edit.py b/test/test_edit.py index f853df6a3..a48b20856 100644 --- a/test/test_edit.py +++ b/test/test_edit.py @@ -21,8 +21,6 @@ from test._common import unittest from test.helper import TestHelper, control_stdin from beets import library -from beetsplug.edit import EditPlugin -from beets.ui import UserError class ModifyFileMocker(object): @@ -96,7 +94,7 @@ class EditCommandTest(unittest.TestCase, TestHelper): """Run the edit command, with mocked stdin and yaml writing, and passing `args` to `run_command`.""" m = ModifyFileMocker(**modify_file_args) - with patch.object(EditPlugin, 'get_editor', side_effect=m.action): + with patch('beetsplug.edit.edit', side_effect=m.action): with control_stdin('\n'.join(stdin)): self.run_command('edit', *args) @@ -128,8 +126,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): # edit titles self.run_mocked_command({'replacements': {u't\u00eftle': u'modified t\u00eftle'}}, - # edit? y, done? y, modify? n - ['y', 'y', 'n']) + # Apply changes? n + ['n']) self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') @@ -140,8 +138,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): # edit titles self.run_mocked_command({'replacements': {u't\u00eftle': u'modified t\u00eftle'}}, - # edit? y, done? y, modify? y - ['y', 'y', 'y']) + # Apply changes? y + ['y']) self.assertCounts(write_call_count=self.TRACK_COUNT, title_starts_with=u'modified t\u00eftle') @@ -149,12 +147,12 @@ class EditCommandTest(unittest.TestCase, TestHelper): ['title']) def test_single_title_edit_apply(self): - """Edit title for on items in the library, then apply changes.""" + """Edit title for one item in the library, then apply changes.""" # edit title self.run_mocked_command({'replacements': {u't\u00eftle 9': u'modified t\u00eftle 9'}}, - # edit? y, done? y, modify? y - ['y', 'y', 'y']) + # Apply changes? y + ['y']) self.assertCounts(write_call_count=1,) # no changes except on last item @@ -167,8 +165,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): """Do not edit anything.""" # do not edit anything self.run_mocked_command({'contents': None}, - # edit? n -> "no changes found" - ['n']) + # no stdin + []) self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') @@ -181,8 +179,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): # edit album self.run_mocked_command({'replacements': {u'\u00e4lbum': u'modified \u00e4lbum'}}, - # edit? y, done? y, modify? y - ['y', 'y', 'y']) + # Apply changes? y + ['y']) self.assertCounts(write_call_count=self.TRACK_COUNT) self.assertItemFieldsModified(self.album.items(), self.items_orig, @@ -195,8 +193,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): """Album query (-a), edit album field, apply changes.""" self.run_mocked_command({'replacements': {u'\u00e4lbum': u'modified \u00e4lbum'}}, - # edit? y, done? y, modify? y - ['y', 'y', 'y'], + # Apply changes? y + ['y'], args=['-a']) self.album.load() @@ -209,8 +207,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): """Album query (-a), edit albumartist field, apply changes.""" self.run_mocked_command({'replacements': {u'album artist': u'modified album artist'}}, - # edit? y, done? y, modify? y - ['y', 'y', 'y'], + # Apply changes? y + ['y'], args=['-a']) self.album.load() @@ -219,38 +217,24 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.assertItemFieldsModified(self.album.items(), self.items_orig, ['albumartist']) - def test_malformed_yaml_discard(self): + def test_malformed_yaml(self): """Edit the yaml file incorrectly (resulting in a malformed yaml - document), then discard changes. - - TODO: this test currently fails, on purpose. User gets into an endless - "fix?" -> "ok.fixed." prompt loop unless he is able to provide a - well-formed yaml.""" + document).""" # edit the yaml file to an invalid file - try: - self.run_mocked_command({'contents': '!MALFORMED'}, - # edit? y, done? y, fix? n - ['y', 'y', 'n']) - except UserError as e: - self.fail(repr(e)) + self.run_mocked_command({'contents': '!MALFORMED'}, + # no stdin + []) self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') - def test_invalid_yaml_discard(self): + def test_invalid_yaml(self): """Edit the yaml file incorrectly (resulting in a well-formed but - invalid yaml document), then discard changes. - - TODO: this test currently fails, on purpose. `check_diff()` chokes - ungracefully""" + invalid yaml document).""" # edit the yaml file to an invalid file - try: - self.run_mocked_command({'contents': - 'wellformed: yes, but invalid'}, - # edit? y, done? y - ['y', 'y']) - except KeyError as e: - self.fail(repr(e)) + self.run_mocked_command({'contents': 'wellformed: yes, but invalid'}, + # no stdin + []) self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') From 2adf70209f8e8a2c6a68332c0d6fc620a1604d76 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Sun, 15 Nov 2015 18:36:24 +0100 Subject: [PATCH 54/93] edit: add test for extra fields on user yaml * Add test_invalid_yaml_extra_field, testing the handling of user appended fields (in particular, a non existing field) during the yaml editing. --- test/test_edit.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/test_edit.py b/test/test_edit.py index a48b20856..d3a38e7e5 100644 --- a/test/test_edit.py +++ b/test/test_edit.py @@ -239,6 +239,18 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') + def test_invalid_yaml_extra_field(self): + """Edit the yaml file incorrectly (resulting in a well-formed but + invalid yaml document), appending an extra field to the first item.""" + # append "foo: bar" to item with id == 1 + self.run_mocked_command({'replacements': {u'id: 1': + u'id: 1\nfoo: bar'}}, + # no stdin + []) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From e31680123bd27f46dadaa297a792057c74d1d34e Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Mon, 16 Nov 2015 14:46:13 +0100 Subject: [PATCH 55/93] edit: update extra fields in yaml test * Update test for extra fields in edited yaml, allowing the user to add fields while editing. Rename the test to test_single_edit_add_field to reflect its purpose more accurately. --- test/test_edit.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/test/test_edit.py b/test/test_edit.py index d3a38e7e5..c72eeee73 100644 --- a/test/test_edit.py +++ b/test/test_edit.py @@ -189,6 +189,19 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.album.load() self.assertEqual(self.album.album, u'\u00e4lbum') + def test_single_edit_add_field(self): + """Edit the yaml file appending an extra field to the first item, then + apply changes.""" + # append "foo: bar" to item with id == 1 + self.run_mocked_command({'replacements': {u'id: 1': + u'id: 1\nfoo: bar'}}, + # Apply changes? y + ['y']) + + self.assertEqual(self.lib.items('id:1')[0].foo, 'bar') + self.assertCounts(write_call_count=1, + title_starts_with=u't\u00eftle') + def test_a_album_edit_apply(self): """Album query (-a), edit album field, apply changes.""" self.run_mocked_command({'replacements': {u'\u00e4lbum': @@ -239,18 +252,6 @@ class EditCommandTest(unittest.TestCase, TestHelper): self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') - def test_invalid_yaml_extra_field(self): - """Edit the yaml file incorrectly (resulting in a well-formed but - invalid yaml document), appending an extra field to the first item.""" - # append "foo: bar" to item with id == 1 - self.run_mocked_command({'replacements': {u'id: 1': - u'id: 1\nfoo: bar'}}, - # no stdin - []) - - self.assertCounts(write_call_count=0, - title_starts_with=u't\u00eftle') - def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From 917628340e1abf92cd8f27171c7c48fba2f0b875 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Mon, 16 Nov 2015 14:48:05 +0100 Subject: [PATCH 56/93] edit: save only items with changes * Modify save_write() so only the items that do have pending changes (ie. dirty items) are saved to the database. --- beetsplug/edit.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 3a22d014e..292d606f6 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -232,5 +232,6 @@ class EditPlugin(plugins.BeetsPlugin): # Save to the database and possibly write tags. for ob in objs: - self._log.debug('saving changes to {}', ob) - ob.try_sync(ui.should_write()) + if ob._dirty: + self._log.debug('saving changes to {}', ob) + ob.try_sync(ui.should_write()) From bb39cd509de56c2dc94beeee67c794b12e2b80c6 Mon Sep 17 00:00:00 2001 From: jmwatte Date: Sun, 15 Nov 2015 12:17:50 +0100 Subject: [PATCH 57/93] cleaned up edit.rst --- beetsplug/edit.py | 2 +- docs/plugins/edit.rst | 30 +++++++----------------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 3a22d014e..8e7b0b8d3 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -29,7 +29,7 @@ import os def edit(filename): - """Open `filename` in a test editor. + """Open `filename` in a text editor. """ cmd = util.shlex_split(util.editor_command()) cmd.append(filename) diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst index 45dd042e7..28ac21681 100644 --- a/docs/plugins/edit.rst +++ b/docs/plugins/edit.rst @@ -2,7 +2,7 @@ Edit Plugin ============ The ``edit`` plugin lets you open the tags, fields from a group of items, edit them in a text-editor and save them back. Add the ``edit`` plugin to your ``plugins:`` in your ``config.yaml``. Then -you simply put in a query like you normally do. +simply do a query. :: beet edit beatles @@ -11,8 +11,7 @@ you simply put in a query like you normally do. -You get a list of hits and then you can edit them. The ``edit`` opens your standard text-editor with a list of your hits and for each hit a bunch of fields. - +You get a list of hits to edit.``edit`` opens your standard text-editor with a list of your hits and for each hit a bunch of fields. Without anything specified in your ``config.yaml`` for ``edit:`` you will see for items @@ -25,24 +24,15 @@ and for albums $album-$albumartist -You can get fields from the cmdline by adding +You can get extra fields from the cmdline by adding :: - -f '$genre $added' + -e year -e comments -or - -:: - - -e '$year $comments' - -If you use ``-f '$field ...'`` you get *only* what you specified. - -If you use ``-e '$field ...'`` you get what you specified *extra*. If you add ``--all`` you get all the fields. -After you edit the values in your text-editor - *and you may only edit the values, no deleting fields or adding fields!* - you save the file, answer with ``y`` on ``Done?`` and you get a summary of your changes. Check em, answer ``y`` or ``n`` and the changes are written to your library. +After you edit the values in your text-editor - *and you may only edit the values, no deleting fields or adding fields!* - you save the file and you get a summary of your changes. Check em. Apply the changes into your library. Configuration ------------- @@ -54,12 +44,6 @@ Make a ``edit:`` section in your config.yaml ``(beet config -e)`` albumfields: genre album itemfields: track artist -* The ``albumfields:`` and ``itemfields:`` lets you list the fields you want to change. +* The ``albumfields:`` and ``itemfields:`` is a list of fields you want to change. ``albumfields:`` gets picked if you put ``-a`` in your search query, else ``itemfields:``. For a list of fields - do the ``beet fields`` command. - - -but you can pick anything else. With "<>" it will look like: -:: - - <>02<>The Night Before<>The Beatles<>Help! + do the ``beet fields`` command. Or put in a faulty one, hit enter and you get a list of available field. From feabf1a6ef03a0d5800f7cd2f20198639c0fe076 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 16 Nov 2015 12:27:32 -0800 Subject: [PATCH 58/93] Offer a chance to fix YAML parse errors An alternative to 749ef8563847ef25c1a2816e2097e4e90e69069f by @jmwatte. --- beetsplug/edit.py | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 8e7b0b8d3..16f770b1c 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -166,28 +166,38 @@ class EditPlugin(plugins.BeetsPlugin): If something goes wrong during editing, return None to indicate the process should abort. """ - # Ask the user to edit the data. + # Set up a temporary file with the initial data for editi new = NamedTemporaryFile(suffix='.yaml', delete=False) old_str = dump(data) new.write(old_str) new.close() - edit(new.name) - # Read the data back after editing and check whether anything - # changed. - with open(new.name) as f: - new_str = f.read() - os.remove(new.name) - if new_str == old_str: - ui.print_("No changes; aborting.") - return None - - # Parse the updated data. + # Loop until we have parseable data. try: - return load(new_str) - except yaml.YAMLError as e: - ui.print_("Invalid YAML: {}".format(e)) - return None + while True: + # Ask the user to edit the data. + edit(new.name) + + # Read the data back after editing and check whether anything + # changed. + with open(new.name) as f: + new_str = f.read() + if new_str == old_str: + ui.print_("No changes; aborting.") + return None + + # Parse the updated data. + try: + return load(new_str) + except yaml.YAMLError as e: + ui.print_("Invalid YAML: {}".format(e)) + if not ui.input_yn("Edit again to fix? (Y/n)", True): + return None + + # Remove the temporary file before returning. + finally: + os.remove(new.name) + def apply_data(self, objs, old_data, new_data): """Take potentially-updated data and apply it to a set of Model From d87a7474776d6f498e952c230a1b4ade96ca9e06 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 16 Nov 2015 12:43:59 -0800 Subject: [PATCH 59/93] Condense edit plugin documentation Also avoided adding some unused command-line options. --- beetsplug/edit.py | 2 +- docs/plugins/edit.rst | 59 +++++++++++++++++-------------------------- 2 files changed, 24 insertions(+), 37 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index b31f4c5f5..e444ad3de 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -96,7 +96,7 @@ class EditPlugin(plugins.BeetsPlugin): action='store_true', dest='all', help='edit all fields', ) - edit_command.parser.add_all_common_options() + edit_command.parser.add_album_option() edit_command.func = self._edit_command return [edit_command] diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst index 28ac21681..5f3083948 100644 --- a/docs/plugins/edit.rst +++ b/docs/plugins/edit.rst @@ -1,49 +1,36 @@ Edit Plugin -============ -The ``edit`` plugin lets you open the tags, fields from a group of items, edit them in a text-editor and save them back. -Add the ``edit`` plugin to your ``plugins:`` in your ``config.yaml``. Then -simply do a query. -:: +=========== - beet edit beatles - beet edit beatles -a - beet edit beatles -f '$title $lyrics' +The ``edit`` plugin lets you modify music metadata using your favorite text +editor. +Enable the ``edit`` plugin in your configuration (see :ref:`using-plugins`) and +then type:: + beet edit QUERY -You get a list of hits to edit.``edit`` opens your standard text-editor with a list of your hits and for each hit a bunch of fields. -Without anything specified in your ``config.yaml`` for ``edit:`` you will see +Your text editor (i.e., the command in your ``$EDITOR`` environment variable) +will open with a list of tracks to edit. Make your changes and exit your text +editor to apply them to your music. -for items -:: +Command-Line Options +-------------------- - $track-$title-$artist-$album +The ``edit`` command has these command-line options: -and for albums -:: - - $album-$albumartist - -You can get extra fields from the cmdline by adding -:: - - -e year -e comments - - -If you add ``--all`` you get all the fields. - -After you edit the values in your text-editor - *and you may only edit the values, no deleting fields or adding fields!* - you save the file and you get a summary of your changes. Check em. Apply the changes into your library. +- ``-a`` or ``--album``: Edit albums instead of individual items. +- ``-e FIELD`` or ``--extra FIELD``: Specify an additional field to edit + (in addition to the defaults set in the configuration). +- ``--all``: Edit *all* available fields. Configuration ------------- -Make a ``edit:`` section in your config.yaml ``(beet config -e)`` -:: +To configure the plugin, make an ``edit:`` section in your configuration +file. The available options are: - edit: - albumfields: genre album - itemfields: track artist - -* The ``albumfields:`` and ``itemfields:`` is a list of fields you want to change. - ``albumfields:`` gets picked if you put ``-a`` in your search query, else ``itemfields:``. For a list of fields - do the ``beet fields`` command. Or put in a faulty one, hit enter and you get a list of available field. +- **itemfields**: A space-separated list of item fields to include in the + editor by default. + Default: ``track title artist album`` +- **albumfields**: The same when editing albums (with the ``-a`` option). + Default: ``album albumartist`` From f767f1c2a36d71c8adc4d042ec6d782207840e79 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 16 Nov 2015 13:01:24 -0800 Subject: [PATCH 60/93] New confirmation prompt We now ask for a trinary edit/apply/cancel confirmation *after* showing the updates. This lets you decide whether you're done based on a "preview" of the changes and keep editing if they don't look right. --- beetsplug/edit.py | 76 +++++++++++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index e444ad3de..3a5f180ce 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -137,42 +137,36 @@ class EditPlugin(plugins.BeetsPlugin): return set(fields) def edit(self, album, objs, fields): - """The core editor logic. + """The core editor function. - `album`: A flag indicating whether we're editing Items or Albums. - `objs`: The `Item`s or `Album`s to edit. - `fields`: The set of field names to edit (or None to edit everything). """ - # Get the content to edit as raw data structures. - data = [flatten(o, fields) for o in objs] - # Present the YAML to the user and let her change it. - new_data = self.edit_data(data) - if new_data is None: - # Editing failed. - return - - # Apply the updated metadata to the objects. - self.apply_data(objs, data, new_data) + success = self.edit_objects(objs, fields) # Save the new data. - self.save_write(objs) + if success: + self.save_write(objs) - def edit_data(self, data): - """Dump a data structure to a file as text, ask the user to edit - it, and then read back the updated data. + def edit_objects(self, objs, fields): + """Dump a set of Model objects to a file as text, ask the user + to edit it, and apply any changes to the objects. - If something goes wrong during editing, return None to indicate - the process should abort. + Return a boolean indicating whether the edit succeeded. """ - # Set up a temporary file with the initial data for editi + # Get the content to edit as raw data structures. + old_data = [flatten(o, fields) for o in objs] + + # Set up a temporary file with the initial data for editing. new = NamedTemporaryFile(suffix='.yaml', delete=False) - old_str = dump(data) + old_str = dump(old_data) new.write(old_str) new.close() - # Loop until we have parseable data. + # Loop until we have parseable data and the user confirms. try: while True: # Ask the user to edit the data. @@ -184,21 +178,43 @@ class EditPlugin(plugins.BeetsPlugin): new_str = f.read() if new_str == old_str: ui.print_("No changes; aborting.") - return None + return False # Parse the updated data. try: - return load(new_str) + new_data = load(new_str) except yaml.YAMLError as e: ui.print_("Invalid YAML: {}".format(e)) - if not ui.input_yn("Edit again to fix? (Y/n)", True): - return None + if ui.input_yn("Edit again to fix? (Y/n)", True): + continue + else: + return False + + # Show the changes. + self.apply_data(objs, old_data, new_data) + changed = False + for obj in objs: + changed |= ui.show_model_changes(obj) + if not changed: + ui.print_('No changes to apply.') + return False + + # Confirm the changes. + choice = ui.input_options(('continue Editing', 'apply', 'cancel')) + if choice == 'a': # Apply. + return True + elif choice == 'c': # Cancel. + return False + elif choice == 'e': # Keep editing. + # Reset the temporary changes to the objects. + for objs in objs: + obj.read() + continue # Remove the temporary file before returning. finally: os.remove(new.name) - def apply_data(self, objs, old_data, new_data): """Take potentially-updated data and apply it to a set of Model objects. @@ -230,16 +246,6 @@ class EditPlugin(plugins.BeetsPlugin): def save_write(self, objs): """Save a list of updated Model objects to the database. """ - # Display and confirm the changes. - changed = False - for obj in objs: - changed |= ui.show_model_changes(obj) - if not changed: - ui.print_('No changes to apply.') - return - if not ui.input_yn('Apply changes? (y/n)'): - return - # Save to the database and possibly write tags. for ob in objs: if ob._dirty: From 4db91c8bd252dbd8d175604c3d0b09e14ee25f09 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 16 Nov 2015 13:03:50 -0800 Subject: [PATCH 61/93] Fix a too-long line --- beetsplug/edit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 3a5f180ce..92436d867 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -200,7 +200,9 @@ class EditPlugin(plugins.BeetsPlugin): return False # Confirm the changes. - choice = ui.input_options(('continue Editing', 'apply', 'cancel')) + choice = ui.input_options( + ('continue Editing', 'apply', 'cancel') + ) if choice == 'a': # Apply. return True elif choice == 'c': # Cancel. From 0d459752d9214c3a8462fb0e64a819d70b25863a Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 16 Nov 2015 13:55:12 -0800 Subject: [PATCH 62/93] Update edit plugin tests --- test/test_edit.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/test/test_edit.py b/test/test_edit.py index c72eeee73..0b7b76254 100644 --- a/test/test_edit.py +++ b/test/test_edit.py @@ -126,8 +126,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): # edit titles self.run_mocked_command({'replacements': {u't\u00eftle': u'modified t\u00eftle'}}, - # Apply changes? n - ['n']) + # Cancel. + ['c']) self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') @@ -138,8 +138,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): # edit titles self.run_mocked_command({'replacements': {u't\u00eftle': u'modified t\u00eftle'}}, - # Apply changes? y - ['y']) + # Apply changes. + ['a']) self.assertCounts(write_call_count=self.TRACK_COUNT, title_starts_with=u'modified t\u00eftle') @@ -151,8 +151,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): # edit title self.run_mocked_command({'replacements': {u't\u00eftle 9': u'modified t\u00eftle 9'}}, - # Apply changes? y - ['y']) + # Apply changes. + ['a']) self.assertCounts(write_call_count=1,) # no changes except on last item @@ -179,8 +179,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): # edit album self.run_mocked_command({'replacements': {u'\u00e4lbum': u'modified \u00e4lbum'}}, - # Apply changes? y - ['y']) + # Apply changes. + ['a']) self.assertCounts(write_call_count=self.TRACK_COUNT) self.assertItemFieldsModified(self.album.items(), self.items_orig, @@ -195,8 +195,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): # append "foo: bar" to item with id == 1 self.run_mocked_command({'replacements': {u'id: 1': u'id: 1\nfoo: bar'}}, - # Apply changes? y - ['y']) + # Apply changes. + ['a']) self.assertEqual(self.lib.items('id:1')[0].foo, 'bar') self.assertCounts(write_call_count=1, @@ -206,8 +206,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): """Album query (-a), edit album field, apply changes.""" self.run_mocked_command({'replacements': {u'\u00e4lbum': u'modified \u00e4lbum'}}, - # Apply changes? y - ['y'], + # Apply changes. + ['a'], args=['-a']) self.album.load() @@ -220,8 +220,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): """Album query (-a), edit albumartist field, apply changes.""" self.run_mocked_command({'replacements': {u'album artist': u'modified album artist'}}, - # Apply changes? y - ['y'], + # Apply changes. + ['a'], args=['-a']) self.album.load() @@ -235,8 +235,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): document).""" # edit the yaml file to an invalid file self.run_mocked_command({'contents': '!MALFORMED'}, - # no stdin - []) + # Edit again to fix? No. + ['n']) self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') From 8e9d335a87a9741ed1ed4f0eb0c409cc72f422fa Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 17 Nov 2015 10:26:36 -0800 Subject: [PATCH 63/93] Tear down Item.write mock --- test/test_edit.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/test_edit.py b/test/test_edit.py index 0b7b76254..56a13525a 100644 --- a/test/test_edit.py +++ b/test/test_edit.py @@ -16,12 +16,10 @@ from __future__ import (division, absolute_import, print_function, unicode_literals) import codecs -from mock import Mock, patch +from mock import patch from test._common import unittest from test.helper import TestHelper, control_stdin -from beets import library - class ModifyFileMocker(object): """Helper for modifying a file, replacing or editing its contents. Used for @@ -84,9 +82,11 @@ class EditCommandTest(unittest.TestCase, TestHelper): item in self.album.items()] # keep track of write()s - library.Item.write = Mock() + self.write_patcher = patch('beets.library.Item.write') + self.mock_write = self.write_patcher.start() def tearDown(self): + self.write_patcher.stop() self.teardown_beets() self.unload_plugins() @@ -103,7 +103,7 @@ class EditCommandTest(unittest.TestCase, TestHelper): """Several common assertions on Album, Track and call counts.""" self.assertEqual(len(self.lib.albums()), album_count) self.assertEqual(len(self.lib.items()), track_count) - self.assertEqual(library.Item.write.call_count, write_call_count) + self.assertEqual(self.mock_write.call_count, write_call_count) self.assertTrue(all(i.title.startswith(title_starts_with) for i in self.lib.items())) From f68dc4652aa7d505e18c151f255992592d92507d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 17 Nov 2015 13:14:15 -0800 Subject: [PATCH 64/93] Fix a typo --- beetsplug/edit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 92436d867..63cdef815 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -209,7 +209,7 @@ class EditPlugin(plugins.BeetsPlugin): return False elif choice == 'e': # Keep editing. # Reset the temporary changes to the objects. - for objs in objs: + for obj in objs: obj.read() continue From b33d25a0adf79957a0798fc729a6dedafcb03ece Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 17 Nov 2015 14:39:40 -0800 Subject: [PATCH 65/93] --extra option can use any field Not just the built-in fields. --- beetsplug/edit.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 63cdef815..3cd014402 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -19,7 +19,6 @@ from __future__ import (division, absolute_import, print_function, 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 @@ -85,11 +84,9 @@ class EditPlugin(plugins.BeetsPlugin): ) edit_command.parser.add_option( '-e', '--extra', + metavar='FIELD', action='append', - type='choice', - choices=library.Item.all_keys() + - library.Album.all_keys(), - help='add additional fields to edit', + help='edit this field also', ) edit_command.parser.add_option( '--all', From 775a48eb28ce6e505a25ec2744448f2dbfee294a Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 17 Nov 2015 14:40:52 -0800 Subject: [PATCH 66/93] --extra/-e is now --field/-f --- beetsplug/edit.py | 4 ++-- docs/plugins/edit.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 3cd014402..719fbd032 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -83,7 +83,7 @@ class EditPlugin(plugins.BeetsPlugin): help='interactively edit metadata' ) edit_command.parser.add_option( - '-e', '--extra', + '-f', '--field', metavar='FIELD', action='append', help='edit this field also', @@ -112,7 +112,7 @@ class EditPlugin(plugins.BeetsPlugin): if opts.all: fields = None else: - fields = self._get_fields(opts.album, opts.extra) + fields = self._get_fields(opts.album, opts.field) self.edit(opts.album, objs, fields) def _get_fields(self, album, extra): diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst index 5f3083948..507d56950 100644 --- a/docs/plugins/edit.rst +++ b/docs/plugins/edit.rst @@ -19,7 +19,7 @@ Command-Line Options The ``edit`` command has these command-line options: - ``-a`` or ``--album``: Edit albums instead of individual items. -- ``-e FIELD`` or ``--extra FIELD``: Specify an additional field to edit +- ``-f FIELD`` or ``--field FIELD``: Specify an additional field to edit (in addition to the defaults set in the configuration). - ``--all``: Edit *all* available fields. From 3176b83cd76807cfc48773cc282f0ee021a62c70 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 19 Nov 2015 15:25:49 -0800 Subject: [PATCH 67/93] Use type-based formatting and parsing All editable values are now strings and are parsed from strings. This closely matches the behavior of the `modify` command, for example. --- beets/dbcore/db.py | 5 +++++ beetsplug/edit.py | 21 +++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index df796a134..b5da1c89e 100644 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -466,6 +466,11 @@ class Model(object): return cls._type(key).parse(string) + def set_parse(self, key, string): + """Set the object's key to a value represented by a string. + """ + self[key] = self._parse(key, string) + # Database controller and supporting interfaces. diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 719fbd032..2798c162c 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -55,14 +55,27 @@ def flatten(obj, fields): """Represent `obj`, a `dbcore.Model` object, as a dictionary for serialization. Only include the given `fields` if provided; otherwise, include everything. + + The resulting dictionary's keys are all human-readable strings. """ - d = dict(obj) + d = dict(obj.formatted()) if fields: return {k: v for k, v in d.items() if k in fields} else: return d +def apply(obj, data): + """Set the fields of a `dbcore.Model` object according to a + dictionary. + + This is the opposite of `flatten`. The `data` dictionary should have + strings as values. + """ + for key, value in data.items(): + obj.set_parse(key, value) + + class EditPlugin(plugins.BeetsPlugin): def __init__(self): @@ -233,14 +246,14 @@ class EditPlugin(plugins.BeetsPlugin): forbidden = False for key in ignore_fields: if old_dict.get(key) != new_dict.get(key): - self._log.warn('ignoring object where {} changed', key) + self._log.warn('ignoring object whose {} changed', key) forbidden = True break if forbidden: continue - obj = obj_by_id[old_dict['id']] - obj.update(new_dict) + id = int(old_dict['id']) + apply(obj_by_id[id], new_dict) def save_write(self, objs): """Save a list of updated Model objects to the database. From 0873d314197448fabd02d43b8c50a7b6a487956a Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 19 Nov 2015 15:34:49 -0800 Subject: [PATCH 68/93] Catch unexpected YAML type We need a sequence of dictionaries back; validate this assumption. --- beetsplug/edit.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 2798c162c..9b86ca94a 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -27,6 +27,12 @@ from tempfile import NamedTemporaryFile import os +class ParseError(Exception): + """The modified file is unreadable. The user should be offered a chance to + fix the error. + """ + + def edit(filename): """Open `filename` in a text editor. """ @@ -36,7 +42,7 @@ def edit(filename): def dump(arg): - """Dump an object as YAML for editing. + """Dump a sequence of dictionaries as YAML for editing. """ return yaml.safe_dump_all( arg, @@ -46,9 +52,23 @@ def dump(arg): def load(s): - """Read a YAML string back to an object. + """Read a sequence of YAML documents back to a list of dictionaries. + + Can raise a `ParseError`. """ - return list(yaml.load_all(s)) + try: + out = [] + for d in yaml.load_all(s): + if not isinstance(d, dict): + raise ParseError( + 'each entry must be a dictionary; found {}'.format( + type(d).__name__ + ) + ) + out.append(d) + except yaml.YAMLError as e: + raise ParseError('invalid YAML: {}'.format(e)) + return out def flatten(obj, fields): @@ -193,8 +213,8 @@ class EditPlugin(plugins.BeetsPlugin): # Parse the updated data. try: new_data = load(new_str) - except yaml.YAMLError as e: - ui.print_("Invalid YAML: {}".format(e)) + except ParseError as e: + ui.print_("Could not read data: {}".format(e)) if ui.input_yn("Edit again to fix? (Y/n)", True): continue else: From 0e20770cc3ffa56d6466d9df8faae215dcdd6773 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 19 Nov 2015 15:38:20 -0800 Subject: [PATCH 69/93] Convert YAML keys and values back to strings I hadn't quite realized before that the user could also change the *keys* to be non-strings too! This also prevents against that by just reinterpreting everything as strings. --- beetsplug/edit.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 9b86ca94a..f1cf85095 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -52,7 +52,8 @@ def dump(arg): def load(s): - """Read a sequence of YAML documents back to a list of dictionaries. + """Read a sequence of YAML documents back to a list of dictionaries + with string keys and values. Can raise a `ParseError`. """ @@ -65,7 +66,12 @@ def load(s): type(d).__name__ ) ) - out.append(d) + + # Convert all keys and values to strings. They started out + # as strings, but the user may have inadvertently messed + # this up. + out.append({unicode(k): unicode(v) for k, v in d.items()}) + except yaml.YAMLError as e: raise ParseError('invalid YAML: {}'.format(e)) return out From 6a99eaae35897518ed2090616e86d842c92dff73 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 19 Nov 2015 15:45:06 -0800 Subject: [PATCH 70/93] Fix a test for the new output format --- test/test_edit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_edit.py b/test/test_edit.py index 56a13525a..8e6ac6e7d 100644 --- a/test/test_edit.py +++ b/test/test_edit.py @@ -193,8 +193,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): """Edit the yaml file appending an extra field to the first item, then apply changes.""" # append "foo: bar" to item with id == 1 - self.run_mocked_command({'replacements': {u'id: 1': - u'id: 1\nfoo: bar'}}, + self.run_mocked_command({'replacements': {u"id: '1'": + u"id: '1'\nfoo: bar"}}, # Apply changes. ['a']) From 437959018cc9b41c546200cf31a6143a261e83d8 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 19 Nov 2015 16:07:20 -0800 Subject: [PATCH 71/93] Pass through certain "safe" types to YAML This avoids some round-tripping problems with types (such as ScaledInt) that are not represented in strings with 100% fidelity. It also makes the syntax nicer when editing numbers and booleans: they no longer appear to be needlessly surrounded by quotes in the YAML. --- beetsplug/edit.py | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index f1cf85095..9cc1d44bc 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -20,6 +20,7 @@ from __future__ import (division, absolute_import, print_function, from beets import plugins from beets import util from beets import ui +from beets.dbcore import types from beets.ui.commands import _do_query import subprocess import yaml @@ -27,6 +28,11 @@ from tempfile import NamedTemporaryFile import os +# These "safe" types can avoid the format/parse cycle that most fields go +# through: they are safe to edit with native YAML types. +SAFE_TYPES = (types.Float, types.Integer, types.Boolean) + + class ParseError(Exception): """The modified file is unreadable. The user should be offered a chance to fix the error. @@ -53,7 +59,7 @@ def dump(arg): def load(s): """Read a sequence of YAML documents back to a list of dictionaries - with string keys and values. + with string keys. Can raise a `ParseError`. """ @@ -67,10 +73,9 @@ def load(s): ) ) - # Convert all keys and values to strings. They started out - # as strings, but the user may have inadvertently messed - # this up. - out.append({unicode(k): unicode(v) for k, v in d.items()}) + # Convert all keys to strings. They started out as strings, + # but the user may have inadvertently messed this up. + out.append({unicode(k): v for k, v in d.items()}) except yaml.YAMLError as e: raise ParseError('invalid YAML: {}'.format(e)) @@ -82,9 +87,21 @@ def flatten(obj, fields): serialization. Only include the given `fields` if provided; otherwise, include everything. - The resulting dictionary's keys are all human-readable strings. + The resulting dictionary's keys are strings and the values are + safely YAML-serializable types. """ - d = dict(obj.formatted()) + # Format each value. + d = {} + for key, value in obj.items(): + typ = obj._type(key) + if isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type): + # A safe value that is faithfully representable in YAML. + d[key] = value + else: + # A value that should be edited as a string. + d[key] = obj.formatted()[key] + + # Possibly filter field names. if fields: return {k: v for k, v in d.items() if k in fields} else: @@ -99,7 +116,15 @@ def apply(obj, data): strings as values. """ for key, value in data.items(): - obj.set_parse(key, value) + typ = obj._type(key) + if isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type): + # A safe value *stayed* represented as a safe type. Assign it + # directly. + obj[key] = value + else: + # Either the field was stringified originally or the user changed + # it from a safe type to an unsafe one. Parse it as a string. + obj.set_parse(key, unicode(value)) class EditPlugin(plugins.BeetsPlugin): From f995ab38db14e6376e96d8383a193852c41895f2 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 19 Nov 2015 16:11:45 -0800 Subject: [PATCH 72/93] Fix a test and a bug revealed by a test --- beetsplug/edit.py | 3 ++- test/test_edit.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 9cc1d44bc..d93678b5e 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -92,8 +92,9 @@ def flatten(obj, fields): """ # Format each value. d = {} - for key, value in obj.items(): + for key in obj.keys(): typ = obj._type(key) + value = obj[key] if isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type): # A safe value that is faithfully representable in YAML. d[key] = value diff --git a/test/test_edit.py b/test/test_edit.py index 8e6ac6e7d..ae0500029 100644 --- a/test/test_edit.py +++ b/test/test_edit.py @@ -193,8 +193,8 @@ class EditCommandTest(unittest.TestCase, TestHelper): """Edit the yaml file appending an extra field to the first item, then apply changes.""" # append "foo: bar" to item with id == 1 - self.run_mocked_command({'replacements': {u"id: '1'": - u"id: '1'\nfoo: bar"}}, + self.run_mocked_command({'replacements': {u"id: 1": + u"id: 1\nfoo: bar"}}, # Apply changes. ['a']) From 69ffb83453b01fda373c91eb8a1527cf71c2eeb9 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 19 Nov 2015 16:26:27 -0800 Subject: [PATCH 73/93] Eliminate some copypasta --- beetsplug/edit.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index d93678b5e..8d6b168f5 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -82,6 +82,17 @@ def load(s): return out +def _safe_value(obj, key, value): + """Check whether the `value` is safe to represent in YAML and trust as + returned from parsed YAML. + + This ensures that values do not change their type when the user edits their + YAML representation. + """ + typ = obj._type(key) + return isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type) + + def flatten(obj, fields): """Represent `obj`, a `dbcore.Model` object, as a dictionary for serialization. Only include the given `fields` if provided; @@ -93,9 +104,8 @@ def flatten(obj, fields): # Format each value. d = {} for key in obj.keys(): - typ = obj._type(key) value = obj[key] - if isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type): + if _safe_value(obj, key, value): # A safe value that is faithfully representable in YAML. d[key] = value else: @@ -117,8 +127,7 @@ def apply(obj, data): strings as values. """ for key, value in data.items(): - typ = obj._type(key) - if isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type): + if _safe_value(obj, key, value): # A safe value *stayed* represented as a safe type. Assign it # directly. obj[key] = value From 67eb0ed54c60d2cab69616238aa802e98838cdcf Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Fri, 4 Dec 2015 20:51:25 +0100 Subject: [PATCH 74/93] Format track duration as H:MM instead of seconds * Modify library.Item in order to have length formatted as H:MM instead of the raw number of seconds by using a types.Float subclass (DurationType). * Add library.DurationType, with custom format() and parse() methods that handle the conversion. * Add dbcore.query.DurationQuery as a NumericQuery subclass that _convert()s the ranges specified by the user to floats, delegating the rest of the functionality in the parent NumericQuery class. * Add ui.raw_seconds_short() as the reverse of human_seconds_short(). This function uses a regular expression in order to allow any number of minutes, and always required SS to have two digits. --- beets/dbcore/query.py | 29 +++++++++++++++++++++++++++++ beets/library.py | 21 ++++++++++++++++++++- beets/ui/__init__.py | 13 +++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index f0adac665..9308ba0b3 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -653,6 +653,35 @@ class DateQuery(FieldQuery): return clause, subvals +class DurationQuery(NumericQuery): + """NumericQuery that allow human-friendly (M:SS) time interval formats. + + Converts the range(s) to a float value, and delegates on NumericQuery. + + Raises InvalidQueryError when the pattern does not represent an int, float + or M:SS time interval. + """ + def _convert(self, s): + """Convert a M:SS or numeric string to a float. + + Return None if `s` is empty. + Raise an InvalidQueryError if the string cannot be converted. + """ + if not s: + return None + try: + # TODO: tidy up circular import + from beets.ui import raw_seconds_short + return raw_seconds_short(s) + except ValueError: + try: + return float(s) + except ValueError: + raise InvalidQueryArgumentTypeError( + s, + "a M:SS string or a float") + + # Sorting. class Sort(object): diff --git a/beets/library.py b/beets/library.py index 870c46856..13b0b92fa 100644 --- a/beets/library.py +++ b/beets/library.py @@ -195,6 +195,25 @@ class MusicalKey(types.String): return self.parse(key) +class DurationType(types.Float): + """Human-friendly (M:SS) representation of a time interval.""" + query = dbcore.query.DurationQuery + + def format(self, value): + return beets.ui.human_seconds_short(value or 0.0) + + def parse(self, string): + try: + # Try to format back hh:ss to seconds. + return beets.ui.raw_seconds_short(value) + except ValueError: + # Fall back to a plain float.. + try: + return float(string) + except ValueError: + return self.null + + # Library-specific sort types. class SmartArtistSort(dbcore.query.Sort): @@ -426,7 +445,7 @@ class Item(LibModel): 'original_day': types.PaddedInt(2), 'initial_key': MusicalKey(), - 'length': types.FLOAT, + 'length': DurationType(), 'bitrate': types.ScaledInt(1000, u'kbps'), 'format': types.STRING, 'samplerate': types.ScaledInt(1000, u'kHz'), diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index c51c3acb6..10266b537 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -416,6 +416,19 @@ def human_seconds_short(interval): return u'%i:%02i' % (interval // 60, interval % 60) +def raw_seconds_short(string): + """Formats a human-readable M:SS string as a float (number of seconds). + + Raises ValueError if the conversion cannot take place due to `string` not + being in the right format. + """ + match = re.match('^(\d+):([0-5]\d)$', string) + if not match: + raise ValueError('String not in M:SS format') + minutes, seconds = map(int, match.groups()) + return float(minutes*60 + seconds) + + # Colorization. # ANSI terminal colorization code heavily inspired by pygments: From 8d2fda790bf72df06b05aa2a3125a1f0fcd33437 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Sat, 5 Dec 2015 13:28:56 +0100 Subject: [PATCH 75/93] Add config option for human vs raw track duration * Add "format_raw_length" global configuration option to allow the user to toggle between human-readable (default) or raw formatting of track durations. --- beets/config_default.yaml | 1 + beets/library.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index ba58debe7..545fa9638 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -65,6 +65,7 @@ ui: format_item: $artist - $album - $title format_album: $albumartist - $album time_format: '%Y-%m-%d %H:%M:%S' +format_raw_length: no sort_album: albumartist+ album+ sort_item: artist+ album+ disc+ track+ diff --git a/beets/library.py b/beets/library.py index 13b0b92fa..77c4bf8ab 100644 --- a/beets/library.py +++ b/beets/library.py @@ -200,7 +200,11 @@ class DurationType(types.Float): query = dbcore.query.DurationQuery def format(self, value): - return beets.ui.human_seconds_short(value or 0.0) + # TODO: decide if documenting format_raw_length + if not beets.config['format_raw_length'].get(bool): + return beets.ui.human_seconds_short(value or 0.0) + else: + return value def parse(self, string): try: From 62ee915aac952d3d0af3a3bc22cda453cde721be Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Sat, 5 Dec 2015 14:12:27 +0100 Subject: [PATCH 76/93] Fix pyflakes issues, variable name --- beets/dbcore/query.py | 4 ++-- beets/library.py | 2 +- beets/ui/__init__.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 9308ba0b3..a33a2946a 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -678,8 +678,8 @@ class DurationQuery(NumericQuery): return float(s) except ValueError: raise InvalidQueryArgumentTypeError( - s, - "a M:SS string or a float") + s, + "a M:SS string or a float") # Sorting. diff --git a/beets/library.py b/beets/library.py index 77c4bf8ab..d8eb9d5d3 100644 --- a/beets/library.py +++ b/beets/library.py @@ -209,7 +209,7 @@ class DurationType(types.Float): def parse(self, string): try: # Try to format back hh:ss to seconds. - return beets.ui.raw_seconds_short(value) + return beets.ui.raw_seconds_short(string) except ValueError: # Fall back to a plain float.. try: diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 10266b537..3a25a03ff 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -426,7 +426,7 @@ def raw_seconds_short(string): if not match: raise ValueError('String not in M:SS format') minutes, seconds = map(int, match.groups()) - return float(minutes*60 + seconds) + return float(minutes * 60 + seconds) # Colorization. From cca307c88b5721d91de720f98f6ff70b9f325d35 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Sat, 5 Dec 2015 14:18:23 +0100 Subject: [PATCH 77/93] Fix test that was expecting raw length format --- test/test_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_info.py b/test/test_info.py index aaabed980..4a85b6dc9 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -111,7 +111,7 @@ class InfoTest(unittest.TestCase, TestHelper): self.add_item_fixtures() out = self.run_with_output('--library', '--format', '$track. $title - $artist ($length)') - self.assertEqual(u'02. tïtle 0 - the artist (1.1)\n', out) + self.assertEqual(u'02. tïtle 0 - the artist (0:01)\n', out) def suite(): From 0e6427599387a39d0f725f7a8e724b30ccbb9bd3 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:07:01 +0100 Subject: [PATCH 78/93] Add tests for library-specific field types --- test/test_library.py | 51 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/test_library.py b/test/test_library.py index 30b0d6fc4..04fa863ae 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -25,6 +25,7 @@ import shutil import re import unicodedata import sys +import time from test import _common from test._common import unittest @@ -1126,6 +1127,56 @@ class ParseQueryTest(unittest.TestCase): beets.library.parse_query_string(b"query", None) +class LibraryFieldTypesTest(unittest.TestCase): + """Test format() and parse() for library-specific field types""" + def test_datetype(self): + t = beets.library.DateType() + + # format + self.assertEqual('1973-11-29 22:33:09', t.format(123456789)) + # parse + self.assertEqual(123456789.0, t.parse('1973-11-29 22:33:09')) + self.assertEqual(123456789.0, t.parse('123456789.0')) + self.assertEqual(t.null, t.parse('not123456789.0')) + self.assertEqual(t.null, t.parse('1973-11-29')) + + def test_pathtype(self): + t = beets.library.PathType() + + # format + self.assertEqual('/tmp', t.format('/tmp')) + self.assertEqual(u'/tmp/\xe4lbum', t.format(u'/tmp/\u00e4lbum')) + # parse + self.assertEqual(b'/tmp', t.parse('/tmp')) + self.assertEqual(b'/tmp/\xc3\xa4lbum', t.parse(u'/tmp/\u00e4lbum/')) + + def test_musicalkey(self): + t = beets.library.MusicalKey() + + # parse + self.assertEqual('C#m', t.parse('c#m')) + self.assertEqual('Gm', t.parse('g minor')) + self.assertEqual('Not c#m', t.parse('not C#m')) + + def test_durationtype(self): + t = beets.library.DurationType() + + # format + self.assertEqual('1:01', t.format(61.23)) + self.assertEqual('60:01', t.format(3601.23)) + self.assertEqual('0:00', t.format(None)) + # parse + self.assertEqual(61.0, t.parse('1:01')) + self.assertEqual(61.23, t.parse('61.23')) + self.assertEqual(3601.0, t.parse('60:01')) + self.assertEqual(t.null, t.parse('1:00:01')) + self.assertEqual(t.null, t.parse('not61.23')) + # config format_raw_length + beets.config['format_raw_length'] = True + self.assertEqual(61.23, t.format(61.23)) + self.assertEqual(3601.23, t.format(3601.23)) + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From 4afdbdfdf4e78ad9b8f55432cbb6f4ea564e7a8f Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:14:08 +0100 Subject: [PATCH 79/93] Move raw_seconds_short to beets.util --- beets/dbcore/query.py | 4 +--- beets/library.py | 2 +- beets/ui/__init__.py | 13 ------------- beets/util/__init__.py | 13 +++++++++++++ 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index a33a2946a..d8a3a0ea8 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -670,9 +670,7 @@ class DurationQuery(NumericQuery): if not s: return None try: - # TODO: tidy up circular import - from beets.ui import raw_seconds_short - return raw_seconds_short(s) + return util.raw_seconds_short(s) except ValueError: try: return float(s) diff --git a/beets/library.py b/beets/library.py index d8eb9d5d3..ed6b39280 100644 --- a/beets/library.py +++ b/beets/library.py @@ -209,7 +209,7 @@ class DurationType(types.Float): def parse(self, string): try: # Try to format back hh:ss to seconds. - return beets.ui.raw_seconds_short(string) + return util.raw_seconds_short(string) except ValueError: # Fall back to a plain float.. try: diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 3a25a03ff..c51c3acb6 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -416,19 +416,6 @@ def human_seconds_short(interval): return u'%i:%02i' % (interval // 60, interval % 60) -def raw_seconds_short(string): - """Formats a human-readable M:SS string as a float (number of seconds). - - Raises ValueError if the conversion cannot take place due to `string` not - being in the right format. - """ - match = re.match('^(\d+):([0-5]\d)$', string) - if not match: - raise ValueError('String not in M:SS format') - minutes, seconds = map(int, match.groups()) - return float(minutes * 60 + seconds) - - # Colorization. # ANSI terminal colorization code heavily inspired by pygments: diff --git a/beets/util/__init__.py b/beets/util/__init__.py index e2d09c3ab..55c599a05 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -843,3 +843,16 @@ def case_sensitive(path): lower = _windows_long_path_name(path.lower()) upper = _windows_long_path_name(path.upper()) return lower != upper + + +def raw_seconds_short(string): + """Formats a human-readable M:SS string as a float (number of seconds). + + Raises ValueError if the conversion cannot take place due to `string` not + being in the right format. + """ + match = re.match('^(\d+):([0-5]\d)$', string) + if not match: + raise ValueError('String not in M:SS format') + minutes, seconds = map(int, match.groups()) + return float(minutes * 60 + seconds) From a5ecc77663b806c75478c2af4e14f70a86db4b29 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:30:20 +0100 Subject: [PATCH 80/93] Add documentation for M:SS length --- beets/library.py | 3 +-- docs/changelog.rst | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/beets/library.py b/beets/library.py index ed6b39280..f8d226dbe 100644 --- a/beets/library.py +++ b/beets/library.py @@ -200,7 +200,6 @@ class DurationType(types.Float): query = dbcore.query.DurationQuery def format(self, value): - # TODO: decide if documenting format_raw_length if not beets.config['format_raw_length'].get(bool): return beets.ui.human_seconds_short(value or 0.0) else: @@ -211,7 +210,7 @@ class DurationType(types.Float): # Try to format back hh:ss to seconds. return util.raw_seconds_short(string) except ValueError: - # Fall back to a plain float.. + # Fall back to a plain float. try: return float(string) except ValueError: diff --git a/docs/changelog.rst b/docs/changelog.rst index a78bfb727..003319267 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,11 @@ New: singles compilation, "1." See :ref:`not_query`. :bug:`819` :bug:`1728` * :doc:`/plugins/info`: The plugin now accepts the ``-f/--format`` option for customizing how items are displayed. :bug:`1737` +* Track length is now displayed as ``M:SS`` by default, instead of displaying + the raw number of seconds. Queries on track length also accept this format: + for example, ``beet list length:5:30..`` will find all your tracks that have + a duration over 5 minutes and 30 seconds. You can toggle this setting off + via the ``format_raw_length`` configuration option. :bug:`1749` For developers: From 2f2cdd24da39912db9588a98f54a7743d2d5a16e Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:31:45 +0100 Subject: [PATCH 81/93] Fix unused import leftover on test_library --- test/test_library.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_library.py b/test/test_library.py index 04fa863ae..072d7e195 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -25,7 +25,6 @@ import shutil import re import unicodedata import sys -import time from test import _common from test._common import unittest From 25cb556ea2fc74df1fe8060621275dd11e77246b Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:40:14 +0100 Subject: [PATCH 82/93] Fix test that depended on local time --- test/test_library.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/test_library.py b/test/test_library.py index 072d7e195..41d2ec8f8 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -25,6 +25,7 @@ import shutil import re import unicodedata import sys +import time from test import _common from test._common import unittest @@ -1132,7 +1133,10 @@ class LibraryFieldTypesTest(unittest.TestCase): t = beets.library.DateType() # format - self.assertEqual('1973-11-29 22:33:09', t.format(123456789)) + self.assertEqual(time.strftime(beets.config['time_format']. + get(unicode), + time.localtime(123456789)), + t.format(123456789)) # parse self.assertEqual(123456789.0, t.parse('1973-11-29 22:33:09')) self.assertEqual(123456789.0, t.parse('123456789.0')) From 3e2d2479b5b08c0550fd3c158a63ba442a5aeb71 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 9 Dec 2015 16:42:47 +0100 Subject: [PATCH 83/93] Fix test that depended on local time, 2 --- test/test_library.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/test_library.py b/test/test_library.py index 41d2ec8f8..860227a13 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1133,12 +1133,11 @@ class LibraryFieldTypesTest(unittest.TestCase): t = beets.library.DateType() # format - self.assertEqual(time.strftime(beets.config['time_format']. - get(unicode), - time.localtime(123456789)), - t.format(123456789)) + time_local = time.strftime(beets.config['time_format'].get(unicode), + time.localtime(123456789)) + self.assertEqual(time_local, t.format(123456789)) # parse - self.assertEqual(123456789.0, t.parse('1973-11-29 22:33:09')) + self.assertEqual(123456789.0, t.parse(time_local)) self.assertEqual(123456789.0, t.parse('123456789.0')) self.assertEqual(t.null, t.parse('not123456789.0')) self.assertEqual(t.null, t.parse('1973-11-29')) From 37250ef27b5b725744537177c854f033ba1c7f1c Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 12 Dec 2015 16:40:42 -0800 Subject: [PATCH 84/93] fetchart: Fix #1610: itunes install docs --- docs/plugins/fetchart.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index c04517b11..beaf7f951 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -126,10 +126,13 @@ iTunes Store To use the iTunes Store as an art source, install the `python-itunes`_ library. You can do this using `pip`_, like so:: - $ pip install python-itunes + $ pip install https://github.com/ocelma/python-itunes/archive/master.zip +(There's currently `a problem`_ that prevents a plain ``pip install +python-itunes`` from working.) Once the library is installed, the plugin will use it to search automatically. +.. _a problem: https://github.com/ocelma/python-itunes/issues/9 .. _python-itunes: https://github.com/ocelma/python-itunes .. _pip: http://pip.openplans.org/ From 3314db2f77cb6b4a5ac4639e990a6ade08472144 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 12 Dec 2015 16:44:55 -0800 Subject: [PATCH 85/93] fetchart: Better logging for iTunes (#1760) --- beetsplug/fetchart.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 1b2089b58..81137dc28 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -196,11 +196,19 @@ class ITunesStore(ArtSource): try: # Isolate bugs in the iTunes library while searching. try: - itunes_album = itunes.search_album(search_string)[0] + results = itunes.search_album(search_string) except Exception as exc: self._log.debug('iTunes search failed: {0}', exc) return + # Get the first match. + if results: + itunes_album = results[0] + else: + self._log.debug('iTunes search for {:r} got no results', + search_string) + return + if itunes_album.get_artwork()['100']: small_url = itunes_album.get_artwork()['100'] big_url = small_url.replace('100x100', '1200x1200') From 5597313ea01201b1fddb688c1ae7c767f80aae58 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 12 Dec 2015 16:47:00 -0800 Subject: [PATCH 86/93] fetchart: Remove Google backend (fix #1760) --- beetsplug/fetchart.py | 31 +------------------------------ docs/changelog.rst | 2 ++ docs/plugins/fetchart.rst | 19 +++++-------------- 3 files changed, 8 insertions(+), 44 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 81137dc28..a56f9f95a 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -157,34 +157,6 @@ class AlbumArtOrg(ArtSource): self._log.debug(u'no image found on page') -class GoogleImages(ArtSource): - URL = 'https://ajax.googleapis.com/ajax/services/search/images' - - def get(self, album): - """Return art URL from google.org given an album title and - interpreter. - """ - if not (album.albumartist and album.album): - return - search_string = (album.albumartist + ',' + album.album).encode('utf-8') - response = self.request(self.URL, params={ - 'v': '1.0', - 'q': search_string, - 'start': '0', - }) - - # Get results using JSON. - try: - results = response.json() - data = results['responseData'] - dataInfo = data['results'] - for myUrl in dataInfo: - yield myUrl['unescapedUrl'] - except: - self._log.debug(u'error scraping art page') - return - - class ITunesStore(ArtSource): # Art from the iTunes Store. def get(self, album): @@ -388,7 +360,7 @@ class FileSystem(ArtSource): # Try each source in turn. -SOURCES_ALL = [u'coverart', u'itunes', u'amazon', u'albumart', u'google', +SOURCES_ALL = [u'coverart', u'itunes', u'amazon', u'albumart', u'wikipedia'] ART_SOURCES = { @@ -396,7 +368,6 @@ ART_SOURCES = { u'itunes': ITunesStore, u'albumart': AlbumArtOrg, u'amazon': Amazon, - u'google': GoogleImages, u'wikipedia': Wikipedia, } diff --git a/docs/changelog.rst b/docs/changelog.rst index eceda093c..b1cddc59e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -73,6 +73,8 @@ Fixes: ImageMagick on Windows. :bug:`1721` * Fix a crash when writing some Unicode comment strings to MP3s that used older encodings. The encoding is now always updated to UTF-8. :bug:`879` +* :doc:`/plugins/fetchart`: The Google Images backend has been removed. It + used an API that has been shut down. :bug:`1760` .. _Emby Server: http://emby.media diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index beaf7f951..80149d478 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -50,7 +50,7 @@ file. The available options are: - **sources**: List of sources to search for images. An asterisk `*` expands to all available sources. Default: ``coverart itunes amazon albumart``, i.e., everything but - ``wikipedia`` and ``google``. Enable those two sources for more matches at + ``wikipedia``. Enable those two sources for more matches at the cost of some speed. Note: ``minwidth`` and ``enforce_ratio`` options require either `ImageMagick`_ @@ -94,7 +94,7 @@ no resizing is performed for album art found on the filesystem---only downloaded art is resized. Server-side resizing can also be slower than local resizing, so consider installing one of the two backends for better performance. -When using ImageMagic, beets looks for the ``convert`` executable in your path. +When using ImageMagick, beets looks for the ``convert`` executable in your path. On some versions of Windows, the program can be shadowed by a system-provided ``convert.exe``. On these systems, you may need to modify your ``%PATH%`` environment variable so that ImageMagick comes first or use Pillow instead. @@ -106,8 +106,9 @@ Album Art Sources ----------------- By default, this plugin searches for art in the local filesystem as well as on -the Cover Art Archive, the iTunes Store, Amazon, AlbumArt.org, -and Google Image Search, and Wikipedia, in that order. You can reorder the sources or remove +the Cover Art Archive, the iTunes Store, Amazon, and AlbumArt.org, in that +order. +You can reorder the sources or remove some to speed up the process using the ``sources`` configuration option. When looking for local album art, beets checks for image files located in the @@ -136,16 +137,6 @@ Once the library is installed, the plugin will use it to search automatically. .. _python-itunes: https://github.com/ocelma/python-itunes .. _pip: http://pip.openplans.org/ -Google Image Search -''''''''''''''''''' - -You can optionally search for cover art on `Google Images`_. This option uses -the first hit for a search query consisting of the artist and album name. It -is therefore approximate: "incorrect" image matches are possible (although -unlikely). - -.. _Google Images: http://images.google.com/ - Embedding Album Art ------------------- From b31f8cd802477171b0e224ec845ef7159fd903dc Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 12 Dec 2015 16:58:49 -0800 Subject: [PATCH 87/93] Remove tests for Google fetchart backend (#1760) --- test/test_art.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/test/test_art.py b/test/test_art.py index 04bfe3eed..cb29f3769 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -226,38 +226,6 @@ class AAOTest(_common.TestCase): self.assertEqual(list(res), []) -class GoogleImageTest(_common.TestCase): - - _google_url = 'https://ajax.googleapis.com/ajax/services/search/images' - - def setUp(self): - super(GoogleImageTest, self).setUp() - self.source = fetchart.GoogleImages(logger) - - @responses.activate - def run(self, *args, **kwargs): - super(GoogleImageTest, self).run(*args, **kwargs) - - def mock_response(self, url, json): - responses.add(responses.GET, url, body=json, - content_type='application/json') - - def test_google_art_finds_image(self): - album = _common.Bag(albumartist="some artist", album="some album") - json = b"""{"responseData": {"results": - [{"unescapedUrl": "url_to_the_image"}]}}""" - self.mock_response(self._google_url, json) - result_url = self.source.get(album) - self.assertEqual(list(result_url)[0], 'url_to_the_image') - - def test_google_art_dont_finds_image(self): - album = _common.Bag(albumartist="some artist", album="some album") - json = b"""bla blup""" - self.mock_response(self._google_url, json) - result_url = self.source.get(album) - self.assertEqual(list(result_url), []) - - class ArtImporterTest(UseThePlugin): def setUp(self): super(ArtImporterTest, self).setUp() From 3855fa0766dfe54a11497afd29ab22b287018976 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 12 Dec 2015 17:33:23 -0800 Subject: [PATCH 88/93] Doc refinements for #1749 --- docs/changelog.rst | 4 ++-- docs/reference/query.rst | 5 +++++ test/rsrc/unicode’d.mp3 | Bin 12820 -> 12820 bytes 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e75764c30..2df44a999 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,8 +29,8 @@ New: * Track length is now displayed as ``M:SS`` by default, instead of displaying the raw number of seconds. Queries on track length also accept this format: for example, ``beet list length:5:30..`` will find all your tracks that have - a duration over 5 minutes and 30 seconds. You can toggle this setting off - via the ``format_raw_length`` configuration option. :bug:`1749` + a duration over 5 minutes and 30 seconds. You can turn off this new behavior + using the ``format_raw_length`` configuration option. :bug:`1749` For developers: diff --git a/docs/reference/query.rst b/docs/reference/query.rst index 1349d755e..82a16869d 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -144,6 +144,11 @@ and this command finds MP3 files with bitrates of 128k or lower:: $ beet list format:MP3 bitrate:..128000 +The ``length`` field also lets you use a "M:SS" format. For example, this +query finds tracks that are less than four and a half minutes in length:: + + $ beet list length:..4:30 + .. _datequery: diff --git a/test/rsrc/unicode’d.mp3 b/test/rsrc/unicode’d.mp3 index ef732eba6268c44cbeee5f40246e156b259ad04a..a1418194129a59975c475decb4e845c71c2389a3 100644 GIT binary patch delta 47 zcmbP|G9_igBw6OvymW?=j8ui>{M_8sypm!DXMbN`1_p)<1_tJh>+dl!GH>okymW?=j8ui>{M_8sypm!DXMbN`1_p) Date: Sat, 12 Dec 2015 18:03:12 -0800 Subject: [PATCH 89/93] snake_case variable names --- beetsplug/lyrics.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 0bd49589c..584891789 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -454,28 +454,28 @@ class Google(Backend): BY_TRANS = ['by', 'par', 'de', 'von'] LYRICS_TRANS = ['lyrics', 'paroles', 'letras', 'liedtexte'] - def is_page_candidate(self, urlLink, urlTitle, title, artist): + def is_page_candidate(self, url_link, url_title, title, artist): """Return True if the URL title makes it a good candidate to be a page that contains lyrics of title by artist. """ title = self.slugify(title.lower()) artist = self.slugify(artist.lower()) sitename = re.search(u"//([^/]+)/.*", - self.slugify(urlLink.lower())).group(1) - urlTitle = self.slugify(urlTitle.lower()) + self.slugify(url_link.lower())).group(1) + url_title = self.slugify(url_title.lower()) # Check if URL title contains song title (exact match) - if urlTitle.find(title) != -1: + if url_title.find(title) != -1: return True # or try extracting song title from URL title and check if # they are close enough tokens = [by + '_' + artist for by in self.BY_TRANS] + \ [artist, sitename, sitename.replace('www.', '')] + \ self.LYRICS_TRANS - songTitle = re.sub(u'(%s)' % u'|'.join(tokens), u'', urlTitle) - songTitle = songTitle.strip('_|') - typoRatio = .9 - ratio = difflib.SequenceMatcher(None, songTitle, title).ratio() - return ratio >= typoRatio + song_title = re.sub(u'(%s)' % u'|'.join(tokens), u'', url_title) + song_title = song_title.strip('_|') + typo_ratio = .9 + ratio = difflib.SequenceMatcher(None, song_title, title).ratio() + return ratio >= typo_ratio def fetch(self, artist, title): query = u"%s %s" % (artist, title) @@ -492,12 +492,12 @@ class Google(Backend): if 'items' in data.keys(): for item in data['items']: - urlLink = item['link'] - urlTitle = item.get('title', u'') - if not self.is_page_candidate(urlLink, urlTitle, + url_link = item['link'] + url_title = item.get('title', u'') + if not self.is_page_candidate(url_link, url_title, title, artist): continue - html = self.fetch_url(urlLink) + html = self.fetch_url(url_link) lyrics = scrape_lyrics_from_html(html) if not lyrics: continue From 5a285cc11f2456f63ed9a4f14d99b69813ad6a4e Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 12 Dec 2015 18:09:24 -0800 Subject: [PATCH 90/93] Fix #1673: Escape regex terms in lyrics --- beetsplug/lyrics.py | 4 ++++ docs/changelog.rst | 3 +++ test/test_lyrics.py | 11 +++++++++++ 3 files changed, 18 insertions(+) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 584891789..1af34df99 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -463,15 +463,19 @@ class Google(Backend): sitename = re.search(u"//([^/]+)/.*", self.slugify(url_link.lower())).group(1) url_title = self.slugify(url_title.lower()) + # Check if URL title contains song title (exact match) if url_title.find(title) != -1: return True + # or try extracting song title from URL title and check if # they are close enough tokens = [by + '_' + artist for by in self.BY_TRANS] + \ [artist, sitename, sitename.replace('www.', '')] + \ self.LYRICS_TRANS + tokens = [re.escape(t) for t in tokens] song_title = re.sub(u'(%s)' % u'|'.join(tokens), u'', url_title) + song_title = song_title.strip('_|') typo_ratio = .9 ratio = difflib.SequenceMatcher(None, song_title, title).ratio() diff --git a/docs/changelog.rst b/docs/changelog.rst index 305157bf0..88e74fe5a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -84,6 +84,9 @@ Fixes: older encodings. The encoding is now always updated to UTF-8. :bug:`879` * :doc:`/plugins/fetchart`: The Google Images backend has been removed. It used an API that has been shut down. :bug:`1760` +* :doc:`/plugins/lyrics`: Fix a crash in the Google backend when searching for + bands with regular-expression characters in their names, like Sunn O))). + :bug:`1673` .. _Emby Server: http://emby.media diff --git a/test/test_lyrics.py b/test/test_lyrics.py index 33b8c6bb5..515e96587 100644 --- a/test/test_lyrics.py +++ b/test/test_lyrics.py @@ -376,6 +376,17 @@ class LyricsGooglePluginTest(unittest.TestCase): self.assertEqual(google.is_page_candidate(url, urlTitle, s['title'], s['artist']), False, url) + def test_is_page_candidate_special_chars(self): + """Ensure that `is_page_candidate` doesn't crash when the artist + and such contain special regular expression characters. + """ + # https://github.com/sampsyo/beets/issues/1673 + s = self.source + url = s['url'] + s['path'] + url_title = u'foo' + + google.is_page_candidate(url, url_title, s['title'], 'Sunn O)))') + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From f5448d15318c0acd0edbd3b3c85568101686091e Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 12 Dec 2015 18:20:05 -0800 Subject: [PATCH 91/93] scrub: Run on import in auto mode (#1657) --- beetsplug/scrub.py | 21 +++++++-------------- docs/changelog.rst | 3 +++ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index 4636b477d..550d32bee 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -45,9 +45,6 @@ _MUTAGEN_FORMATS = { } -scrubbing = False - - class ScrubPlugin(BeetsPlugin): """Removes extraneous metadata from files' tags.""" def __init__(self): @@ -55,15 +52,12 @@ class ScrubPlugin(BeetsPlugin): self.config.add({ 'auto': True, }) - self.register_listener("write", self.write_item) + + if self.config['auto']: + self.register_listener("import_task_files", self.import_task_files) def commands(self): def scrub_func(lib, opts, args): - # This is a little bit hacky, but we set a global flag to - # avoid autoscrubbing when we're also explicitly scrubbing. - global scrubbing - scrubbing = True - # Walk through matching files and remove tags. for item in lib.items(ui.decargs(args)): self._log.info(u'scrubbing: {0}', @@ -92,8 +86,6 @@ class ScrubPlugin(BeetsPlugin): mf.art = art mf.save() - scrubbing = False - scrub_cmd = ui.Subcommand('scrub', help='clean audio tags') scrub_cmd.parser.add_option('-W', '--nowrite', dest='write', action='store_false', default=True, @@ -140,8 +132,9 @@ class ScrubPlugin(BeetsPlugin): self._log.error(u'could not scrub {0}: {1}', util.displayable_path(path), exc) - def write_item(self, item, path, tags): - """Automatically embed art into imported albums.""" - if not scrubbing and self.config['auto']: + def import_task_files(self, session, task): + """Automatically scrub imported files.""" + for item in task.imported_items(): + path = item.path self._log.debug(u'auto-scrubbing {0}', util.displayable_path(path)) self._scrub(path) diff --git a/docs/changelog.rst b/docs/changelog.rst index 88e74fe5a..d6627089e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -87,6 +87,9 @@ Fixes: * :doc:`/plugins/lyrics`: Fix a crash in the Google backend when searching for bands with regular-expression characters in their names, like Sunn O))). :bug:`1673` +* :doc:`/plugins/scrub`: In ``auto`` mode, the plugin now *actually* only + scrubs files on import---not every time files were written, as it previously + did. :bug:`1657` .. _Emby Server: http://emby.media From d5c51dd816bfedb6a0721af3d29de55d22b665b1 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 12 Dec 2015 18:28:58 -0800 Subject: [PATCH 92/93] scrub: Restore tags & art in auto mode (#1657) --- beetsplug/scrub.py | 57 +++++++++++++++++++++++++--------------------- docs/changelog.rst | 2 ++ 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index 550d32bee..13f2cfc2c 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -62,29 +62,7 @@ class ScrubPlugin(BeetsPlugin): for item in lib.items(ui.decargs(args)): self._log.info(u'scrubbing: {0}', util.displayable_path(item.path)) - - # Get album art if we need to restore it. - if opts.write: - try: - mf = mediafile.MediaFile(util.syspath(item.path), - config['id3v23'].get(bool)) - except IOError as exc: - self._log.error(u'could not open file to scrub: {0}', - exc) - art = mf.art - - # Remove all tags. - self._scrub(item.path) - - # Restore tags, if enabled. - if opts.write: - self._log.debug(u'writing new tags after scrub') - item.try_write() - if art: - self._log.info(u'restoring art') - mf = mediafile.MediaFile(util.syspath(item.path)) - mf.art = art - mf.save() + self._scrub_item(item, opts.write) scrub_cmd = ui.Subcommand('scrub', help='clean audio tags') scrub_cmd.parser.add_option('-W', '--nowrite', dest='write', @@ -132,9 +110,36 @@ class ScrubPlugin(BeetsPlugin): self._log.error(u'could not scrub {0}: {1}', util.displayable_path(path), exc) + def _scrub_item(self, item, restore=True): + """Remove tags from an Item's associated file and, if `restore` + is enabled, write the database's tags back to the file. + """ + # Get album art if we need to restore it. + if restore: + try: + mf = mediafile.MediaFile(util.syspath(item.path), + config['id3v23'].get(bool)) + except IOError as exc: + self._log.error(u'could not open file to scrub: {0}', + exc) + art = mf.art + + # Remove all tags. + self._scrub(item.path) + + # Restore tags, if enabled. + if restore: + self._log.debug(u'writing new tags after scrub') + item.try_write() + if art: + self._log.info(u'restoring art') + mf = mediafile.MediaFile(util.syspath(item.path)) + mf.art = art + mf.save() + def import_task_files(self, session, task): """Automatically scrub imported files.""" for item in task.imported_items(): - path = item.path - self._log.debug(u'auto-scrubbing {0}', util.displayable_path(path)) - self._scrub(path) + self._log.debug(u'auto-scrubbing {0}', + util.displayable_path(item.path)) + self._scrub_item(item) diff --git a/docs/changelog.rst b/docs/changelog.rst index d6627089e..7f07bbb1c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -90,6 +90,8 @@ Fixes: * :doc:`/plugins/scrub`: In ``auto`` mode, the plugin now *actually* only scrubs files on import---not every time files were written, as it previously did. :bug:`1657` +* :doc:`/plugins/scrub`: Also in ``auto`` mode, album art is now correctly + restored. :bug:`1657` .. _Emby Server: http://emby.media From 7749cba00e1968bbf1b80c8ffc2b87e1798e86e2 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 12 Dec 2015 18:29:49 -0800 Subject: [PATCH 93/93] scrub: Demote a log message to debug This seems unnecessary in the `beet scrub` output. --- beetsplug/scrub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index 13f2cfc2c..4e37ad7ff 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -132,7 +132,7 @@ class ScrubPlugin(BeetsPlugin): self._log.debug(u'writing new tags after scrub') item.try_write() if art: - self._log.info(u'restoring art') + self._log.debug(u'restoring art') mf = mediafile.MediaFile(util.syspath(item.path)) mf.art = art mf.save()