Space Panda

atag.py

Source

#!/usr/bin/env python3
"""Commandline utility to change audio file metadata in bulk
The idea is that you can either change tags in bulk, like
- date/year,
- artist (sortable artist),
- album title (sortable album title),
- auto-iterate the tracknumber
Or run your preferred text editor and edit these tags in form of a temporary
YAML-file; the changes are then written to the files' metadata section.
For this to work you better have the environment variable `VISUAL` or `EDITOR`
set to something meaningful (or set the --editor parameter).
This tool uses mutagen, so everything that's supported by mutagen can be
edited here, too.
Here are a few examples on how to use atag.py.
Auto-number all mp3 files in a directory and set the artist:
    atag.py --auto-numbering --artist "Bedřich Smetana" *.mp3
Actually, they should be sorted as "Smetana" and not as "Bedřich":
    atag.py --sort-artist "Smetana, Bedřich" *.mp3
Edit all files by hand in your favorite text editor:
    atag.py --edit *.mp3
For more commandline options, try the --help parameter.
"""
__version__ = '0.2.1'
import argparse
import pathlib
import tempfile
import shutil
import shlex
import subprocess
import os
import yaml
import mutagen
import mutagen.easymp4
EDIT_HELPTEXT = """ # The following tags are available
# title - the title of a track
# titlesort - the sortable title
# artist - the artist of a track
# artistsort - the sortable artist name
# album - the name of the album
# albumsort - the sortable album name
# tracknumber - either directly the number of the track or
#               track number/number of total tracks
# tvshow - the name of the TV show
# tvshowsort - the sortable name of the TV show
# tvseason - the season of the TV show
# tvepisode - the episode in this season of the TV show
#
# There are more, please consult https://mutagen.readthedocs.io/
"""
def parse_args():
    parser = argparse.ArgumentParser()
    parser.add_argument('--title', '-t',
                        type=str,
                        default=None,
                        help="Set track title")
    parser.add_argument('--sort-title', '-T',
                        type=str,
                        default=None,
                        help="Set track sort-title")
    parser.add_argument('--artist', '-a',
                        type=str,
                        default=None,
                        help="Set artist")
    parser.add_argument('--sort-artist', '-A',
                        type=str,
                        default=None,
                        help="Set sort-artist")
    parser.add_argument('--album', '-b',
                        type=str,
                        default=None,
                        help="Set album title")
    parser.add_argument('--sort-album', '-B',
                        type=str,
                        default=None,
                        help="Set album sort-title")
    parser.add_argument('--tv-show',
                        type=str,
                        default=None,
                        help="Set TV show name")
    parser.add_argument('--sort-tv-show',
                        type=str,
                        default=None,
                        help="Set TV show sortable name")
    parser.add_argument('--date',
                        type=str,
                        default=None,
                        help="Set the date")
    parser.add_argument('--season',
                        type=str,
                        default=None,
                        help="Set TV show season")
    parser.add_argument('--episode',
                        type=str,
                        default=None,
                        help="Set TV show episode")
    parser.add_argument('--number', '-n',
                        type=int,
                        default=None,
                        help="Set the track number")
    parser.add_argument('--total-tracks',
                        type=int,
                        default=None,
                        help="Set the number of total tracks on this album")
    parser.add_argument('--auto-numbering', '-N',
                        action="store_true",
                        default=False,
                        help="Automatically number the files")
    parser.add_argument('--dummy', '-d',
                        action="store_true",
                        default=False,
                        help="Don't actually apply changes, just show what would be saved")
    parser.add_argument('--clear',
                        action="store_true",
                        default=False,
                        help="Clear all metadata before applying these changes. DANGEROUS OPTION, run with -d first!")
    parser.add_argument('--edit', '-e',
                        action="store_true",
                        default=False,
                        help="Edit in external editor after applying all changes")
    parser.add_argument('--editor',
                        type=str,
                        default=None,
                        help="What text editor to use with --edit. Defaults to VISUAL or EDITOR.")
    parser.add_argument('--list', '-l',
                        action="store_true",
                        default=False,
                        help="List the tags of the files")
    parser.add_argument("files",
                        nargs="+",
                        help="Files to work with/on, use '-' to read filenames from stdin")
                        
    return parser.parse_args()
def main():
    args = parse_args()
    if args.files == ['-']:
        raise NotImplementedError()
    files = [pathlib.Path(fn).expanduser().resolve() for fn in args.files]
    if args.list:
        for fn in sorted(files):
            meta = mutagen.File(fn, easy=True)
            if meta is None:
                continue
            print(f"{fn.name}: {meta.tags}")
        return
    tags = {}
    if args.title is not None:
        tags['title'] = args.title
    if args.sort_title is not None:
        tags['titlesort'] = args.sort_title
    if args.artist is not None:
        tags['artist'] = args.artist
    if args.sort_artist is not None:
        tags['artistsort'] = args.sort_artist
    if args.album is not None:
        tags['album'] = args.album
    if args.sort_album is not None:
        tags['albumsort'] = args.sort_album
    if args.date is not None:
        tags['date'] = args.date
    if args.number is not None:
        tags['tracknumber'] = [args.number]
        if args.total_tracks is not None:
            tags['tracknumber'].append(args.total_tracks)
    if args.tv_show is not None:
        tags['tvshow'] = args.tv_show
    if args.sort_tv_show is not None:
        tags['tvshowsort'] = args.sort_tv_show
    if args.season is not None:
        tags['tvseason'] = args.season
    if args.episode is not None:
        tags['tvepisode'] = args.episode
    changes = {}
    for nr, fn in enumerate(sorted(files)):
        meta = mutagen.File(fn, easy=True)
        if meta is None:
            raise RuntimeError(f"Unknown file type for {fn}")
        if args.clear:
            meta.clear()
        meta.update(tags)
        if args.auto_numbering:
            meta['tracknumber'] = f"{nr+1}/{len(files)}"
        elif args.number is None and args.total_tracks is not None and 'tracknumber' in meta:
            tracknumber = meta['tracknumber']
            if isinstance(tracknumber, list):
                tracknumber = tracknumber[0]
            if '/' in tracknumber:
                tracknumber, _ = tracknumber.split('/')
            meta['tracknumber'] = f"{tracknumber}/{args.total_tracks}"
        changes[str(fn)] = meta
    if args.edit:
        editor = args.editor
        if editor is None:
            # resolve the default editor
            for name in ['VISUAL', 'EDITOR']:
                value = os.getenv(name)
                if value is None:
                    continue
                editor = shutil.which(value)
                if editor is not None:
                    break
        if editor is None:
            raise RuntimeError("Could not find any editor, try setting EDITOR or VISUAL")
        editor = shlex.split(editor)
        with tempfile.NamedTemporaryFile("w+t", encoding="utf-8", suffix=".yaml") as tmpfd:
            tmpfd.write(EDIT_HELPTEXT)
            tmpfd.write(yaml.dump(dict(sorted([(fn, dict(change))
                                               for fn, change in changes.items()])),
                                  indent=4, allow_unicode=True))
            tmpfd.flush()
            subprocess.run(editor + [tmpfd.name])
            tmpfd.flush()
            tmpfd.seek(0)
            for fn, change in yaml.safe_load(tmpfd.read()).items():
                if fn not in changes:
                    continue
                changes[fn].clear()
                changes[fn].update(change)
    for fn, change in changes.items():
        if args.dummy:
            print(f"{fn}: {change}")
        else:
            change.save()
mutagen.easymp4.EasyMP4Tags.RegisterTextKey("tvshow", "tvsh")
mutagen.easymp4.EasyMP4Tags.RegisterTextKey("tvshowsort", "sosn")
mutagen.easymp4.EasyMP4Tags.RegisterIntKey("tvseason", "tvsn")
mutagen.easymp4.EasyMP4Tags.RegisterIntKey("tvepisode", "tves")
if __name__ == '__main__':
    main()