├── README.md ├── albumgain.py ├── ape2id3.py └── compatid3.py /README.md: -------------------------------------------------------------------------------- 1 | `albumgain` 2 | =========== 3 | 4 | `albumgain` is a Python script that processes directories of MP3s 5 | or Ogg Vorbis files and sets their [ReplayGain][1] as an album. 6 | 7 | It was specifically developed to work with my now-lost [Sansa 8 | Fuze][2], and is thus specifically tuned to that player's requirements. 9 | 10 | Flags for the `albumgain` script are: 11 | 12 | - `-t`: Required; specifies the type of music file to process—`mp3` 13 | or `ogg`. 14 | 15 | - `-d`: Dry run. Shows you what commands would be executed instead 16 | of actually executing them. 17 | 18 | Arguments are one or more directories to process. `albumgain` can 19 | group certain kinds of related directories (e.g. `Album (disc 1)` 20 | and `Album (disc 2)`) and apply the same ReplayGain value to both 21 | as one album. 22 | 23 | `albumgain` requires `mutagen`, which you can `pip install`. 24 | 25 | [1]: http://en.wikipedia.org/wiki/ReplayGain 26 | [2]: http://en.wikipedia.org/wiki/Sansa_Fuze 27 | 28 | -------------------------------------------------------------------------------- /albumgain.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright (c) 2010 Matt Behrens. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included 14 | # in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY 17 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 19 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | import fnmatch, getopt, re, os, sys 22 | 23 | import ape2id3 24 | 25 | filetypes = { 26 | 'mp3': ('*.mp3', ['mp3gain', '-k']), 27 | 'ogg': ('*.ogg', ['vorbisgain', '-f', '-a']) 28 | } 29 | 30 | opts, args = getopt.getopt(sys.argv[1:], 'gdt:') 31 | filetype = None 32 | dry_run = False 33 | show_groups = False 34 | for o, a in opts: 35 | if o == '-d': 36 | dry_run = True 37 | if o == '-g': 38 | show_groups = True 39 | if o == '-t': 40 | filetype = filetypes[a] 41 | if filetype is None: 42 | raise SyntaxError, 'must specify filetype with -t' 43 | 44 | disc_strip = re.compile(r'(.+) \((.+ )?disc( .+)?\)') 45 | part_strip = re.compile(r'(.+), Part ') 46 | vol_strip = re.compile(r'(.+), Volume ') 47 | 48 | groups = {} 49 | for top in args: 50 | for dirpath, dirnames, filenames in os.walk(top): 51 | group = [os.path.join(dirpath, filename) for filename 52 | in fnmatch.filter(filenames, filetype[0])] 53 | if group: 54 | m = disc_strip.match(dirpath) 55 | if m: 56 | groupname = m.group(1) 57 | else: 58 | m = part_strip.match(dirpath) 59 | if m: 60 | groupname = m.group(1) 61 | else: 62 | m = vol_strip.match(dirpath) 63 | if m: 64 | groupname = m.group(1) 65 | else: 66 | groupname = dirpath 67 | groups[groupname] = groups.get(groupname, []) + group 68 | 69 | apelog = ape2id3.Logger(3, sys.argv[0]) 70 | ape2id3 = ape2id3.Ape2Id3(apelog, force=True, id3v23=True) 71 | 72 | for groupname, group in groups.items(): 73 | if dry_run: 74 | sys.stderr.write("==> %r\n" % (filetype[1] + group)) 75 | elif show_groups: 76 | sys.stderr.write(groupname + '\n') 77 | else: 78 | if os.spawnvp(os.P_WAIT, filetype[1][0], filetype[1] + group): 79 | sys.exit(1) 80 | if filetype[0] == '*.mp3': 81 | for filename in group: 82 | ape2id3.copy_replaygain_tags(filename) 83 | 84 | # ex:et:sw=4:ts=4 85 | -------------------------------------------------------------------------------- /ape2id3.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # Original version: 4 | # http://zuttobenkyou.wordpress.com/2009/03/26/adding-replay-gain-in-linux-automatically/ 5 | # 6 | 7 | import sys 8 | from optparse import OptionParser 9 | 10 | import mutagen 11 | from mutagen.apev2 import APEv2 12 | from mutagen.id3 import TXXX 13 | from compatid3 import CompatID3 14 | 15 | def convert_gain(gain): 16 | if gain[-3:] == " dB": 17 | gain = gain[:-3] 18 | try: 19 | gain = float(gain) 20 | except ValueError: 21 | raise ValueError, "invalid gain value" 22 | return "%.2f dB" % gain 23 | 24 | def convert_peak(peak): 25 | try: 26 | peak = float(peak) 27 | except ValueError: 28 | raise ValueError, "invalid peak value" 29 | return "%.6f" % peak 30 | 31 | REPLAYGAIN_TAGS = ( 32 | ("mp3gain_album_minmax", None), 33 | ("mp3gain_minmax", None), 34 | ("replaygain_album_gain", convert_gain), 35 | ("replaygain_album_peak", convert_peak), 36 | ("replaygain_track_gain", convert_gain), 37 | ("replaygain_track_peak", convert_peak), 38 | ) 39 | 40 | class Logger(object): 41 | def __init__(self, log_level, prog_name): 42 | self.log_level = log_level 43 | self.prog_name = prog_name 44 | self.filename = None 45 | 46 | def prefix(self, msg): 47 | if self.filename is None: 48 | return msg 49 | return "%s: %s" % (self.filename, msg) 50 | 51 | def debug(self, msg): 52 | if self.log_level >= 4: 53 | print self.prefix(msg) 54 | 55 | def info(self, msg): 56 | if self.log_level >= 3: 57 | print self.prefix(msg) 58 | 59 | def warning(self, msg): 60 | if self.log_level >= 2: 61 | print self.prefix("WARNING: %s" % msg) 62 | 63 | def error(self, msg): 64 | if self.log_level >= 1: 65 | sys.stderr.write("%s: %s\n" % (self.prog_name, msg)) 66 | 67 | def critical(self, msg, retval=1): 68 | self.error(msg) 69 | sys.exit(retval) 70 | 71 | class Ape2Id3(object): 72 | def __init__(self, logger, force=False, id3v23=False): 73 | self.log = logger 74 | self.force = force 75 | self.id3v23 = id3v23 76 | 77 | def convert_tag(self, name, value): 78 | pass 79 | 80 | def copy_replaygain_tag(self, apev2, id3, name, converter=None): 81 | self.log.debug("processing '%s' tag" % name) 82 | 83 | if not apev2.has_key(name): 84 | self.log.info("no APEv2 '%s' tag found, skipping tag" % name) 85 | return False 86 | if not self.force and id3.has_key("TXXX:%s" % name): 87 | self.log.info("ID3 '%s' tag already exists, skpping tag" % name) 88 | return False 89 | 90 | value = str(apev2[name]) 91 | if callable(converter): 92 | self.log.debug("converting APEv2 '%s' tag from '%s'" % 93 | (name, value)) 94 | try: 95 | value = converter(value) 96 | except ValueError: 97 | self.log.warning("invalid value for APEv2 '%s' tag" % name) 98 | return False 99 | self.log.debug("converted APEv2 '%s' tag to '%s'" % (name, value)) 100 | 101 | id3.add(TXXX(encoding=1, desc=name, text=value)) 102 | self.log.info("added ID3 '%s' tag with value '%s'" % (name, value)) 103 | return True 104 | 105 | def copy_replaygain_tags(self, filename): 106 | self.log.filename = filename 107 | self.log.debug("begin processing file") 108 | 109 | try: 110 | apev2 = APEv2(filename) 111 | except mutagen.apev2.error: 112 | self.log.info("no APEv2 tag found, skipping file") 113 | return 114 | except IOError: 115 | e = sys.exc_info() 116 | self.log.error("%s" % e[1]) 117 | return 118 | 119 | try: 120 | id3 = CompatID3(filename) 121 | except mutagen.id3.error: 122 | self.log.info("no ID3 tag found, creating one") 123 | id3 = CompatID3() 124 | 125 | modified = False 126 | for name, converter in REPLAYGAIN_TAGS: 127 | copied = self.copy_replaygain_tag(apev2, id3, name, converter) 128 | if copied: 129 | modified = True 130 | if modified: 131 | if self.id3v23: 132 | self.log.debug("saving modified ID3 tag as ID3v2.3") 133 | id3.update_to_v23() 134 | id3.save(filename, v2=3) 135 | else: 136 | self.log.debug("saving modified ID3 tag as ID3v2.4") 137 | id3.save(filename) 138 | 139 | self.log.debug("done processing file") 140 | self.log.filename = None 141 | 142 | def main(prog_name, options, args): 143 | logger = Logger(options.log_level, prog_name) 144 | ape2id3 = Ape2Id3(logger, force=options.force, id3v23=options.id3v23) 145 | for filename in args: 146 | ape2id3.copy_replaygain_tags(filename) 147 | 148 | if __name__ == "__main__": 149 | parser = OptionParser(version="0.1", usage="%prog [OPTION]... FILE...", 150 | description="Copy APEv2 ReplayGain tags on " 151 | "FILE(s) to ID3v2.") 152 | parser.add_option("-q", "--quiet", dest="log_level", 153 | action="store_const", const=0, default=1, 154 | help="do not output error messages") 155 | parser.add_option("-v", "--verbose", dest="log_level", 156 | action="store_const", const=3, 157 | help="output warnings and informational messages") 158 | parser.add_option("-d", "--debug", dest="log_level", 159 | action="store_const", const=4, 160 | help="output debug messages") 161 | parser.add_option("-f", "--force", dest="force", 162 | action="store_true", default=False, 163 | help="force overwriting of existing ID3v2 " 164 | "ReplayGain tags") 165 | parser.add_option("-3", "--use-id3v2.3", dest="id3v23", 166 | action="store_true", default=False, 167 | help="write ID3v2.3 tags instead of ID3v2.4") 168 | prog_name = parser.get_prog_name() 169 | options, args = parser.parse_args() 170 | 171 | if len(args) < 1: 172 | parser.error("no files specified") 173 | 174 | try: 175 | main(prog_name, options, args) 176 | except KeyboardInterrupt: 177 | pass 178 | 179 | # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: 180 | -------------------------------------------------------------------------------- /compatid3.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Picard, the next-generation MusicBrainz tagger 4 | # Copyright (C) 2006 Lukáš Lalinský 5 | # Copyright (C) 2005 Michael Urman 6 | # 7 | # This program is free software; you can redistribute it and/or 8 | # modify it under the terms of the GNU General Public License 9 | # as published by the Free Software Foundation; either version 2 10 | # of the License, or (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 20 | 21 | import struct 22 | from struct import pack, unpack 23 | import mutagen 24 | from mutagen._util import insert_bytes 25 | from mutagen.id3 import ID3, Frame, Frames, Frames_2_2, TextFrame, TORY, \ 26 | TYER, TIME, APIC, IPLS, TDAT, BitPaddedInt, MakeID3v1 27 | 28 | class TCMP(TextFrame): 29 | pass 30 | 31 | class XDOR(TextFrame): 32 | pass 33 | 34 | class XSOP(TextFrame): 35 | pass 36 | 37 | class CompatID3(ID3): 38 | """ 39 | Additional features over mutagen.id3.ID3: 40 | * ID3v2.3 writing 41 | * iTunes' TCMP frame 42 | """ 43 | 44 | PEDANTIC = False 45 | 46 | def __init__(self, *args, **kwargs): 47 | self.unknown_frames = [] 48 | if args: 49 | known_frames = dict(Frames) 50 | known_frames.update(dict(Frames_2_2)) 51 | known_frames["TCMP"] = TCMP 52 | known_frames["XDOR"] = XDOR 53 | known_frames["XSOP"] = XSOP 54 | kwargs["known_frames"] = known_frames 55 | super(CompatID3, self).__init__(*args, **kwargs) 56 | 57 | def save(self, filename=None, v1=1, v2=4): 58 | """Save changes to a file. 59 | 60 | If no filename is given, the one most recently loaded is used. 61 | 62 | Keyword arguments: 63 | v1 -- if 0, ID3v1 tags will be removed 64 | if 1, ID3v1 tags will be updated but not added 65 | if 2, ID3v1 tags will be created and/or updated 66 | v2 -- version of ID3v2 tags (3 or 4). By default Mutagen saves ID3v2.4 67 | tags. If you want to save ID3v2.3 tags, you must call method 68 | update_to_v23 before saving the file. 69 | 70 | The lack of a way to update only an ID3v1 tag is intentional. 71 | """ 72 | 73 | # Sort frames by 'importance' 74 | order = ["TIT2", "TPE1", "TRCK", "TALB", "TPOS", "TDRC", "TCON"] 75 | order = dict(zip(order, range(len(order)))) 76 | last = len(order) 77 | frames = self.items() 78 | frames.sort(lambda a, b: cmp(order.get(a[0][:4], last), 79 | order.get(b[0][:4], last))) 80 | 81 | framedata = [self.__save_frame(frame, v2) for (key, frame) in frames] 82 | framedata.extend([data for data in self.unknown_frames 83 | if len(data) > 10]) 84 | if not framedata: 85 | try: 86 | self.delete(filename) 87 | except EnvironmentError, err: 88 | from errno import ENOENT 89 | if err.errno != ENOENT: raise 90 | return 91 | 92 | framedata = ''.join(framedata) 93 | framesize = len(framedata) 94 | 95 | if filename is None: filename = self.filename 96 | try: f = open(filename, 'rb+') 97 | except IOError, err: 98 | from errno import ENOENT 99 | if err.errno != ENOENT: raise 100 | f = open(filename, 'ab') # create, then reopen 101 | f = open(filename, 'rb+') 102 | try: 103 | idata = f.read(10) 104 | try: id3, vmaj, vrev, flags, insize = unpack('>3sBBB4s', idata) 105 | except struct.error: id3, insize = '', 0 106 | insize = BitPaddedInt(insize) 107 | if id3 != 'ID3': insize = -10 108 | 109 | if insize >= framesize: outsize = insize 110 | else: outsize = (framesize + 1023) & ~0x3FF 111 | framedata += '\x00' * (outsize - framesize) 112 | 113 | framesize = BitPaddedInt.to_str(outsize, width=4) 114 | flags = 0 115 | header = pack('>3sBBB4s', 'ID3', v2, 0, flags, framesize) 116 | data = header + framedata 117 | 118 | if (insize < outsize): 119 | insert_bytes(f, outsize-insize, insize+10) 120 | f.seek(0) 121 | f.write(data) 122 | 123 | try: 124 | f.seek(-128, 2) 125 | except IOError, err: 126 | from errno import EINVAL 127 | if err.errno != EINVAL: raise 128 | f.seek(0, 2) # ensure read won't get "TAG" 129 | 130 | if f.read(3) == "TAG": 131 | f.seek(-128, 2) 132 | if v1 > 0: f.write(MakeID3v1(self)) 133 | else: f.truncate() 134 | elif v1 == 2: 135 | f.seek(0, 2) 136 | f.write(MakeID3v1(self)) 137 | 138 | finally: 139 | f.close() 140 | 141 | def __save_frame(self, frame, v2): 142 | flags = 0 143 | if self.PEDANTIC and isinstance(frame, TextFrame): 144 | if len(str(frame)) == 0: return '' 145 | framedata = frame._writeData() 146 | if v2 == 3: bits=8 147 | else: bits=7 148 | datasize = BitPaddedInt.to_str(len(framedata), width=4, bits=bits) 149 | header = pack('>4s4sH', type(frame).__name__, datasize, flags) 150 | return header + framedata 151 | 152 | def update_to_v23(self): 153 | """Convert older (and newer) tags into an ID3v2.3 tag. 154 | 155 | This updates incompatible ID3v2 frames to ID3v2.3 ones. If you 156 | intend to save tags as ID3v2.3, you must call this function 157 | at some point. 158 | """ 159 | 160 | if self.version < (2,3,0): del self.unknown_frames[:] 161 | 162 | # TMCL, TIPL -> TIPL 163 | if "TIPL" in self or "TMCL" in self: 164 | people = [] 165 | if "TIPL" in self: 166 | f = self.pop("TIPL") 167 | people.extend(f.people) 168 | if "TMCL" in self: 169 | f = self.pop("TMCL") 170 | people.extend(f.people) 171 | if "IPLS" not in self: 172 | self.add(IPLS(encoding=f.encoding, people=people)) 173 | 174 | # TODO: 175 | # * EQU2 -> EQUA 176 | # * RVA2 -> RVAD 177 | 178 | # TDOR -> TORY 179 | if "TDOR" in self: 180 | f = self.pop("TDOR") 181 | if f.text: 182 | d = f.text[0] 183 | if d.year and "TORY" not in self: 184 | self.add(TORY(encoding=f.encoding, text="%04d" % d.year)) 185 | 186 | # TDRC -> TYER, TDAT, TIME 187 | if "TDRC" in self: 188 | f = self.pop("TDRC") 189 | if f.text: 190 | d = f.text[0] 191 | if d.year and "TYER" not in self: 192 | self.add(TYER(encoding=f.encoding, text="%04d" % d.year)) 193 | if d.month and d.day and "TDAT" not in self: 194 | self.add(TDAT(encoding=f.encoding, text="%02d%02d" % (d.day, d.month))) 195 | if d.hour and d.minute and "TIME" not in self: 196 | self.add(TIME(encoding=f.encoding, text="%02d%02d" % (d.hour, d.minute))) 197 | 198 | if "TCON" in self: 199 | self["TCON"].genres = self["TCON"].genres 200 | 201 | if self.version < (2, 3): 202 | # ID3v2.2 PIC frames are slightly different. 203 | pics = self.getall("APIC") 204 | mimes = { "PNG": "image/png", "JPG": "image/jpeg" } 205 | self.delall("APIC") 206 | for pic in pics: 207 | newpic = APIC( 208 | encoding=pic.encoding, mime=mimes.get(pic.mime, pic.mime), 209 | type=pic.type, desc=pic.desc, data=pic.data) 210 | self.add(newpic) 211 | 212 | # ID3v2.2 LNK frames are just way too different to upgrade. 213 | self.delall("LINK") 214 | 215 | if "TSOP" in self: 216 | f = self.pop("TSOP") 217 | self.add(XSOP(encoding=f.encoding, text=f.text)) 218 | 219 | # New frames added in v2.4. 220 | for key in ["ASPI", "EQU2", "RVA2", "SEEK", "SIGN", "TDRL", "TDTG", 221 | "TMOO", "TPRO", "TSOA", "TSOT", "TSST"]: 222 | if key in self: del(self[key]) 223 | 224 | for frame in self.values(): 225 | # ID3v2.3 doesn't support UTF-8 (and WMP can't read UTF-16 BE) 226 | if hasattr(frame, "encoding"): 227 | if frame.encoding > 1: 228 | frame.encoding = 1 229 | # ID3v2.3 doesn't support multiple values 230 | if isinstance(frame, mutagen.id3.TextFrame): 231 | try: 232 | frame.text = ["/".join(frame.text)] 233 | except TypeError: 234 | frame.text = frame.text[:1] 235 | --------------------------------------------------------------------------------