My file manager of choice is ranger. It's terminal-based, provides keybind mapping for everything making me more efficient in navigating my files, and it's incredibly extensible by enabling the creation of custom commands in python. If that wasn't enough, it also has a ton of other great features (extensible file preview, tabs, tags, ...). Go check out their github page, seriously.
One very useful feature of ranger is the bulkrename
command. It allows you
to open an editor with the filenames of all selected files and edit them. After
saving, a shell script is generated to make the necessary renaming (and you're
given the chance to review it), and just like magic *poof* you just renamed
a bunch of files simultaneously from the convenience of your favorite text
editor.
Here's an example:
Pretty convenient. But when you think about it, it is rather limited. Why only allow file renaming? This same workflow could be used to edit any file attribute. One just needs to provide a way to obtain the attribute for each selected file and to provide the shell script command that changes the attribute to be what the user changed it to.
I recently wanted to modify the Artist ID3 tag of multiple mp3 files at once
which motivated me to write the generic version of ranger's bulkrename
command: bulk
.
For that I basically copied the code for bulkrename
and generalized the file
attribute retrieval and the attribute change command generation, by calling the
get_attribute()
and get_change_attribute_cmd()
, respectively, from a
bulk subcommand stored in the bulk
dictionary.
The bulk
command class is as follows:
class bulk(Command): """:bulk <attribute> This command opens a list with the attribute <attribute> for each of the selected files in an external editor. After you edit and save the file, it will generate a shell script which changes the attributes in bulk according to the changes you did in the file. This shell script is opened in an editor for you to review. After you close it, it will be executed. """ bulk = {} def execute(self): # pylint: disable=too-many-locals,too-many-statements import sys import tempfile from ranger.container.file import File from ranger.ext.shell_escape import shell_escape as esc py3 = sys.version_info[0] >= 3 # get bulk command argument bkname = self.rest(1) # Create and edit the file list files = [f for f in self.fm.thistab.get_selection()] attributes = [self.bulk[bkname].get_attribute(f) for f in files] listfile = tempfile.NamedTemporaryFile(delete=False) listpath = listfile.name if py3: listfile.write("\n".join(attributes).encode("utf-8")) else: listfile.write("\n".join(attributes)) listfile.close() self.fm.execute_file([File(listpath)], app='editor') listfile = open(listpath, 'r') new_attributes = listfile.read().split("\n") listfile.close() os.unlink(listpath) if all(a == b for a, b in zip(attributes, new_attributes)): self.fm.notify("Nothing to be done!") return print(new_attributes) # Generate script cmdfile = tempfile.NamedTemporaryFile() script_lines = [] script_lines.append("# This file will be executed when you close the editor.\n") script_lines.append("# Please double-check everything, clear the file to abort.\n") script_lines.extend("%s\n" % self.bulk[bkname].get_change_attribute_cmd(file, old, new) for old, new, file in zip(attributes, new_attributes, files) if old != new) script_content = "".join(script_lines) if py3: cmdfile.write(script_content.encode("utf-8")) else: cmdfile.write(script_content) cmdfile.flush() # Open the script and let the user review it self.fm.execute_file([File(cmdfile.name)], app='editor') cmdfile.seek(0) # Do the attribute changing self.fm.run(['/bin/sh', cmdfile.name], flags='w') cmdfile.close()
Then, inside this class, I added a class for each of the bulk subcommands that I
wanted to add: id3art
, id3tit
and id3alb
, which change the ID3 tag
for the Artist, Title and Album, respectively.
For each one of those, I implemented the get_attribute()
and
get_change_attribute_cmd()
methods. get_attribute()
receives a ranger's
file object and should return a string with the attribute, in the case of
id3art
, the ID3 Artist tag, which I used the eyed3
python module to
get. get_change_attribute_cmd()
receives a ranger's file object, the old
attribute (the one returned by get_attribute()
) and the new one (the value
edited by the user), and should return a string containing the shell script
command to apply the change made by the user (in the case of id3art
, eyeD3
-a NewArtist fileName.mp3
).
Finally, I also added an entry for each of those subcommands to the bulk
dictionary, which maps the subcommand name to its object.
Translating all of this to code, this is what I added inside the bulk
class:
class id3art(object): def get_attribute(self, file): import eyed3 artist = eyed3.load(file.relative_path).tag.artist return str(artist) if artist else '' def get_change_attribute_cmd(self, file, old, new): from ranger.ext.shell_escape import shell_escape as esc return "eyeD3 -a %s %s" % (esc(new), esc(file)) class id3tit(object): def get_attribute(self, file): import eyed3 title = eyed3.load(file.relative_path).tag.title return str(title) if title else '' def get_change_attribute_cmd(self, file, old, new): from ranger.ext.shell_escape import shell_escape as esc return "eyeD3 -t %s %s" % (esc(new), esc(file)) class id3alb(object): def get_attribute(self, file): import eyed3 album = eyed3.load(file.relative_path).tag.album return str(album) if album else '' def get_change_attribute_cmd(self, file, old, new): from ranger.ext.shell_escape import shell_escape as esc return "eyeD3 -A %s %s" % (esc(new), esc(file)) bulk = {'id3art': id3art(), 'id3tit': id3tit(), 'id3alb': id3alb(), }
Here it is in action:
Since I thought this generic framework for the bulk command could be useful to
everyone using ranger, with each user implementing their own bulk subcommand, I
opened a pull request. It seems the idea was well received, and they even
intend to make bulkrename
simply an alias to a bulk subcommand, so maybe in
the near future you will be able to make your own bulk subcommands using the
built-in bulk
command đŸ™‚.