1#!/usr/bin/python 2 3# Audio Tools, a module and set of tools for manipulating audio data 4# Copyright (C) 2007-2014 Brian Langenberger 5 6# This program is free software; you can redistribute it and/or modify 7# it under the terms of the GNU General Public License as published by 8# the Free Software Foundation; either version 2 of the License, or 9# (at your option) any later version. 10 11# This program is distributed in the hope that it will be useful, 12# but WITHOUT ANY WARRANTY; without even the implied warranty of 13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14# GNU General Public License for more details. 15 16# You should have received a copy of the GNU General Public License 17# along with this program; if not, write to the Free Software 18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 19 20 21import sys 22import os.path 23import audiotools 24import audiotools.ui 25import audiotools.text as _ 26import termios 27 28 29def add_replay_gain(tracks, progress=None): 30 """a wrapper around add_replay_gain that catches KeyboardInterrupt""" 31 32 try: 33 audiotools.add_replay_gain(tracks=tracks, progress=progress) 34 except KeyboardInterrupt: 35 pass 36 37 38if audiotools.ui.AVAILABLE: 39 urwid_present = True 40 urwid = audiotools.ui.urwid 41 42 class Tracktag(urwid.Frame): 43 def __init__(self, tracks, metadata_choices): 44 """tracks[a][t] is an AudioFile object 45 per album "a" and track "t" 46 47 metadata_choices[a][c][t] is a MetaData object 48 per album "a", choice "c" and track "t" 49 """ 50 51 self.__cancelled__ = True 52 53 assert(len(tracks) == len(metadata_choices)) 54 for (album_tracks, choices) in zip(tracks, metadata_choices): 55 for choice in choices: 56 assert(len(album_tracks) == len(choice)) 57 58 self.metadatas_status = [urwid.Text(u"") for a in tracks] 59 self.metadatas = [ 60 audiotools.ui.MetaDataFiller( 61 [audiotools.Filename(t.filename).basename().__unicode__() 62 for t in tracks], 63 choices, 64 status) for (tracks, choices, status) in 65 zip(tracks, metadata_choices, self.metadatas_status)] 66 67 wizard = audiotools.ui.Wizard( 68 pages=self.metadatas, 69 completion_button=urwid.Button(_.LAB_APPLY_BUTTON, 70 on_press=self.apply), 71 cancel_button=urwid.Button(_.LAB_CANCEL_BUTTON, 72 on_press=self.exit), 73 page_changed=self.page_changed) 74 75 self.__current_filler__ = self.metadatas[0] 76 77 urwid.Frame.__init__(self, 78 body=wizard, 79 footer=self.metadatas_status[0]) 80 81 def page_changed(self, filler): 82 self.__current_filler__ = filler 83 self.set_footer(filler.status) 84 85 def apply(self, button): 86 self.__cancelled__ = False 87 raise urwid.ExitMainLoop() 88 89 def exit(self, button): 90 self.__cancelled__ = True 91 raise urwid.ExitMainLoop() 92 93 def cancelled(self): 94 return self.__cancelled__ 95 96 def handle_text(self, i): 97 if i == 'f1': 98 self.__current_filler__.selected_match.select_previous_item() 99 elif i == 'f2': 100 self.__current_filler__.selected_match.select_next_item() 101 102 def populated_metadata(self): 103 """for each album, 104 yields a list of new populated MetaData objects per track 105 to be called once Urwid's main loop has completed""" 106 107 for metadatas in self.metadatas: 108 yield [metadata for (track_id, metadata) in 109 metadatas.selected_match.metadata()] 110 111else: 112 urwid_present = False 113 114 115UPDATE_OPTIONS = {"track_name": ("--name", 116 _.LAB_TRACKTAG_UPDATE_TRACK_NAME), 117 "artist_name": ("--artist", 118 _.LAB_TRACKTAG_UPDATE_ARTIST_NAME), 119 "performer_name": ("--performer", 120 _.LAB_TRACKTAG_UPDATE_PERFORMER_NAME), 121 "composer_name": ("--composer", 122 _.LAB_TRACKTAG_UPDATE_COMPOSER_NAME), 123 "conductor_name": ("--conductor", 124 _.LAB_TRACKTAG_UPDATE_CONDUCTOR_NAME), 125 "album_name": ("--album", 126 _.LAB_TRACKTAG_UPDATE_ALBUM_NAME), 127 "catalog": ("--catalog", 128 _.LAB_TRACKTAG_UPDATE_CATALOG), 129 "track_number": ("--number", 130 _.LAB_TRACKTAG_UPDATE_TRACK_NUMBER), 131 "track_total": ("--track-total", 132 _.LAB_TRACKTAG_UPDATE_TRACK_TOTAL), 133 "album_number": ("--album-number", 134 _.LAB_TRACKTAG_UPDATE_ALBUM_NUMBER), 135 "album_total": ("--album-total", 136 _.LAB_TRACKTAG_UPDATE_ALBUM_TOTAL), 137 "ISRC": ("--ISRC", 138 _.LAB_TRACKTAG_UPDATE_ISRC), 139 "publisher": ("--publisher", 140 _.LAB_TRACKTAG_UPDATE_PUBLISHER), 141 "media": ("--media-type", 142 _.LAB_TRACKTAG_UPDATE_MEDIA), 143 "year": ("--year", 144 _.LAB_TRACKTAG_UPDATE_YEAR), 145 "date": ("--date", 146 _.LAB_TRACKTAG_UPDATE_DATE), 147 "copyright": ("--copyright", 148 _.LAB_TRACKTAG_UPDATE_COPYRIGHT), 149 "comment": ("--comment", 150 _.LAB_TRACKTAG_UPDATE_COMMENT)} 151 152REMOVE_OPTIONS = {"track_name": ("--remove-name", 153 _.LAB_TRACKTAG_REMOVE_TRACK_NAME), 154 "artist_name": ("--remove-artist", 155 _.LAB_TRACKTAG_REMOVE_ARTIST_NAME), 156 "performer_name": ("--remove-performer", 157 _.LAB_TRACKTAG_REMOVE_PERFORMER_NAME), 158 "composer_name": ("--remove-composer", 159 _.LAB_TRACKTAG_REMOVE_COMPOSER_NAME), 160 "conductor_name": ("--remove-conductor", 161 _.LAB_TRACKTAG_REMOVE_CONDUCTOR_NAME), 162 "album_name": ("--remove-album", 163 _.LAB_TRACKTAG_REMOVE_ALBUM_NAME), 164 "catalog": ("--remove-catalog", 165 _.LAB_TRACKTAG_REMOVE_CATALOG), 166 "track_number": ("--remove-number", 167 _.LAB_TRACKTAG_REMOVE_TRACK_NUMBER), 168 "track_total": ("--remove-track-total", 169 _.LAB_TRACKTAG_REMOVE_TRACK_TOTAL), 170 "album_number": ("--remove-album-number", 171 _.LAB_TRACKTAG_REMOVE_ALBUM_NUMBER), 172 "album_total": ("--remove-album-total", 173 _.LAB_TRACKTAG_REMOVE_ALBUM_TOTAL), 174 "ISRC": ("--remove-ISRC", 175 _.LAB_TRACKTAG_REMOVE_ISRC), 176 "publisher": ("--remove-publisher", 177 _.LAB_TRACKTAG_REMOVE_PUBLISHER), 178 "media": ("--remove-media-type", 179 _.LAB_TRACKTAG_REMOVE_MEDIA), 180 "year": ("--remove-year", 181 _.LAB_TRACKTAG_REMOVE_YEAR), 182 "date": ("--remove-date", 183 _.LAB_TRACKTAG_REMOVE_DATE), 184 "copyright": ("--remove-copyright", 185 _.LAB_TRACKTAG_REMOVE_COPYRIGHT), 186 "comment": ("--remove-comment", 187 _.LAB_TRACKTAG_REMOVE_COMMENT)} 188 189if (__name__ == '__main__'): 190 import argparse 191 192 # add an enormous number of options to the parser 193 # neatly categorized for convenience 194 195 parser = argparse.ArgumentParser(description=_.DESCRIPTION_TRACKTAG) 196 197 parser.add_argument("--version", 198 action="version", 199 version="Python Audio Tools %s" % (audiotools.VERSION)) 200 201 parser.add_argument("-I", "--interactive", 202 action="store_true", 203 default=False, 204 dest="interactive", 205 help=_.OPT_INTERACTIVE_METADATA) 206 207 parser.add_argument("-V", "--verbose", 208 dest="verbosity", 209 choices=audiotools.VERBOSITY_LEVELS, 210 default=audiotools.DEFAULT_VERBOSITY, 211 help=_.OPT_VERBOSE) 212 213 text_group = parser.add_argument_group(_.OPT_CAT_TEXT) 214 215 for field in audiotools.MetaData.FIELD_ORDER: 216 if field in UPDATE_OPTIONS: 217 variable = "update_%s" % (field) 218 (option, help_text) = UPDATE_OPTIONS[field] 219 text_group.add_argument( 220 option, 221 type=str if field not in 222 audiotools.MetaData.INTEGER_FIELDS else int, 223 dest=variable, 224 metavar="STRING" if field not in 225 audiotools.MetaData.INTEGER_FIELDS else "INT", 226 help=help_text) 227 228 text_group.add_argument("--comment-file", 229 dest="comment_file", 230 metavar="FILENAME", 231 help=_.OPT_TRACKTAG_COMMENT_FILE) 232 233 parser.add_argument("-r", "--replace", 234 action="store_true", 235 default=False, 236 dest="replace", 237 help=_.OPT_TRACKTAG_REPLACE) 238 239 remove_group = parser.add_argument_group(_.OPT_CAT_REMOVAL) 240 241 for field in audiotools.MetaData.FIELD_ORDER: 242 if field in REMOVE_OPTIONS: 243 variable = "remove_%s" % (field) 244 (option, help_text) = REMOVE_OPTIONS[field] 245 remove_group.add_argument( 246 option, 247 action='store_true', 248 default=False, 249 dest=variable, 250 help=help_text) 251 252 lookup = parser.add_argument_group(_.OPT_CAT_CD_LOOKUP) 253 254 lookup.add_argument("-M", "--metadata-lookup", 255 action="store_true", 256 default=False, 257 dest="metadata_lookup", 258 help=_.OPT_METADATA_LOOKUP) 259 260 lookup.add_argument("--musicbrainz-server", 261 dest="musicbrainz_server", 262 default=audiotools.MUSICBRAINZ_SERVER, 263 metavar="HOSTNAME") 264 265 lookup.add_argument("--musicbrainz-port", 266 type=int, 267 dest="musicbrainz_port", 268 default=audiotools.MUSICBRAINZ_PORT, 269 metavar="PORT") 270 271 lookup.add_argument("--no-musicbrainz", 272 action="store_false", 273 dest="use_musicbrainz", 274 default=audiotools.MUSICBRAINZ_SERVICE, 275 help=_.OPT_NO_MUSICBRAINZ) 276 277 lookup.add_argument("--freedb-server", 278 dest="freedb_server", 279 default=audiotools.FREEDB_SERVER, 280 metavar="HOSTNAME") 281 282 lookup.add_argument("--freedb-port", 283 type=int, 284 dest="freedb_port", 285 default=audiotools.FREEDB_PORT, 286 metavar="PORT") 287 288 lookup.add_argument("--no-freedb", 289 action="store_false", 290 dest="use_freedb", 291 default=audiotools.FREEDB_SERVICE, 292 help=_.OPT_NO_FREEDB) 293 294 lookup.add_argument("-D", "--default", 295 dest="use_default", 296 action="store_true", 297 default=False, 298 help=_.OPT_DEFAULT) 299 300 parser.add_argument("--replay-gain", 301 action="store_true", 302 default=False, 303 dest="add_replay_gain", 304 help=_.OPT_REPLAY_GAIN_TRACKTAG) 305 306 parser.add_argument("--remove-replay-gain", 307 action="store_true", 308 default=False, 309 dest="remove_replay_gain", 310 help=_.OPT_REMOVE_REPLAY_GAIN_TRACKTAG) 311 312 parser.add_argument("-j", "--joint", 313 type=int, 314 default=audiotools.MAX_JOBS, 315 dest="max_processes", 316 help=_.OPT_JOINT) 317 318 parser.add_argument("filenames", 319 metavar="FILENAME", 320 nargs="+", 321 help=_.OPT_INPUT_FILENAME) 322 323 options = parser.parse_args() 324 325 msg = audiotools.Messenger(options.verbosity == "quiet") 326 327 # ensure interactive mode is available, if selected 328 if options.interactive and (not audiotools.ui.AVAILABLE): 329 audiotools.ui.not_available_message(msg) 330 sys.exit(1) 331 332 # open a --comment-file as UTF-8 formatted text file 333 if options.comment_file is not None: 334 try: 335 with open(options.comment_file, "rb") as f: 336 comment_file = f.read().decode('utf-8', 'replace') 337 except IOError: 338 msg.error(_.ERR_TRACKTAG_COMMENT_IOERROR % 339 (audiotools.Filename(options.comment_file),)) 340 sys.exit(1) 341 342 if (((comment_file.count(u"\uFFFD") * 100) // 343 len(comment_file)) >= 10): 344 msg.error(_.ERR_TRACKTAG_COMMENT_NOT_UTF8 % 345 (audiotools.Filename(options.comment_file),)) 346 sys.exit(1) 347 else: 348 comment_file = None 349 350 # open our set of input files for tagging 351 try: 352 tracks = audiotools.open_files(options.filenames, 353 messenger=msg, 354 no_duplicates=True) 355 except audiotools.DuplicateFile as err: 356 msg.error(_.ERR_DUPLICATE_FILE % (err.filename,)) 357 sys.exit(1) 358 359 if len(tracks) == 0: 360 msg.error(_.ERR_1_FILE_REQUIRED) 361 sys.exit(1) 362 363 # album_tracks[a][t] 364 # is an AudioFile object per album "a", per track "t" 365 # of the files to be updated 366 album_tracks = [] 367 368 # album_current_metadatas[a][t] 369 # is a MetaData object per album "a", per track "t" 370 # of the current track metadata 371 album_current_metadatas = [] 372 373 # album_metadata_choices[a][c][t] 374 # is a MetaData object per album "a", per choice "", per "track" 375 album_metadata_choices = [] 376 377 if options.metadata_lookup: 378 # split tracks by album and perform lookup for each 379 for album in audiotools.group_tracks(tracks): 380 album_tracks.append(album) 381 382 album_current_metadatas.append([t.get_metadata() for t in album]) 383 384 try: 385 album_metadata_choices.append( 386 audiotools.track_metadata_lookup( 387 audiofiles=album, 388 musicbrainz_server=options.musicbrainz_server, 389 musicbrainz_port=options.musicbrainz_port, 390 freedb_server=options.freedb_server, 391 freedb_port=options.freedb_port, 392 use_musicbrainz=options.use_musicbrainz, 393 use_freedb=options.use_freedb)) 394 except KeyboardInterrupt: 395 msg.ansi_clearline() 396 msg.error(_.ERR_CANCELLED) 397 sys.exit(1) 398 399 # avoid performing too many lookups too quickly 400 from time import sleep 401 sleep(1) 402 else: 403 # treat tracks as single album and don't perform lookup 404 album_tracks.append(tracks) 405 album_current_metadatas.append([t.get_metadata() for t in tracks]) 406 album_metadata_choices.append( 407 [[audiotools.MetaData() for t in tracks]]) 408 409 if options.replace: 410 # ignore track metadata and treat choices as final metadata 411 pass 412 else: 413 # merge choice metadata with track metadata across all albums 414 for (current_metadatas, 415 metadata_choices) in zip(album_current_metadatas, 416 album_metadata_choices): 417 for choice in metadata_choices: 418 for (old_metadata, 419 new_metadata) in zip(current_metadatas, choice): 420 if old_metadata is not None: 421 for (attr, value) in new_metadata.empty_fields(): 422 setattr(new_metadata, attr, 423 getattr(old_metadata, attr)) 424 425 # apply command-line arguments to all albums and choices 426 for album in album_metadata_choices: 427 for choice in album: 428 for metadata in choice: 429 # apply field removal options across all metadata choices 430 for attr in audiotools.MetaData.FIELD_ORDER: 431 if getattr(options, "remove_%s" % (attr)): 432 delattr(metadata, attr) 433 434 # apply field addition options across all metadata choices 435 for attr in audiotools.MetaData.FIELD_ORDER: 436 if getattr(options, "update_%s" % (attr)) is not None: 437 value = getattr(options, "update_%s" % (attr)) 438 if ((attr not in audiotools.MetaData.INTEGER_FIELDS) and 439 (sys.version_info[0] < 3)): 440 value = value.decode("UTF-8") 441 setattr(metadata, attr, value) 442 443 # apply comment file across all metadata choices, if any 444 if comment_file is not None: 445 metadata.comment = comment_file 446 447 if options.interactive: 448 # run Tracktag widget and get single list of MetaData 449 # for each album's worth of tracks 450 widget = Tracktag(album_tracks, album_metadata_choices) 451 452 loop = audiotools.ui.urwid.MainLoop( 453 widget, 454 audiotools.ui.style(), 455 unhandled_input=widget.handle_text) 456 try: 457 loop.run() 458 msg.ansi_clearscreen() 459 except (termios.error, IOError): 460 msg.error(_.ERR_TERMIOS_ERROR) 461 msg.info(_.ERR_TERMIOS_SUGGESTION) 462 msg.info(audiotools.ui.xargs_suggestion(sys.argv)) 463 sys.exit(1) 464 465 if not widget.cancelled(): 466 to_tag = map(tuple, zip(album_tracks, 467 album_current_metadatas, 468 widget.populated_metadata())) 469 else: 470 sys.exit(0) 471 else: 472 # select choice for each album's worth of tracks 473 # and get a single list of MetaData for each 474 to_tag = map(tuple, 475 zip(album_tracks, 476 album_current_metadatas, 477 [audiotools.ui.select_metadata(choices, 478 msg, 479 options.use_default) 480 for choices in album_metadata_choices])) 481 482 # once all final output metadata is set, 483 # perform actual tagging 484 for (album_tracks, old_metadatas, new_metadatas) in to_tag: 485 if not options.replace: 486 # apply final metadata to tracks using update_metadata 487 # if no full replacement 488 for (old_metadata, 489 (track, 490 new_metadata)) in zip(old_metadatas, 491 zip(album_tracks, new_metadatas)): 492 if old_metadata is not None: 493 # merge new fields with old fields 494 field_updated = False 495 for (attr, value) in new_metadata.fields(): 496 if (getattr(old_metadata, 497 attr) != getattr(new_metadata, 498 attr)): 499 setattr(old_metadata, attr, value) 500 field_updated = True 501 502 if field_updated: 503 # update track if at least one field has changed 504 try: 505 track.update_metadata(old_metadata) 506 except IOError as err: 507 msg.error( 508 _.ERR_ENCODING_ERROR % 509 (audiotools.Filename(track.filename),)) 510 sys.exit(1) 511 else: 512 try: 513 track.set_metadata(new_metadata) 514 except IOError as err: 515 msg.error(_.ERR_ENCODING_ERROR % 516 (audiotools.Filename(track.filename),)) 517 sys.exit(1) 518 else: 519 # apply final metadata to tracks 520 # using set_metadata() if replacement 521 for (track, metadata) in zip(album_tracks, new_metadatas): 522 try: 523 track.set_metadata(metadata) 524 except IOError as err: 525 msg.error(_.ERR_ENCODING_ERROR % 526 (audiotools.Filename(track.filename),)) 527 sys.exit(1) 528 529 # add ReplayGain to tracks, if indicated 530 queue = audiotools.ExecProgressQueue(msg) 531 532 if len(tracks) > 0: 533 for album_tracks in audiotools.group_tracks(tracks): 534 535 album_number = {(m.album_number if m is not None else None) 536 for m in 537 [f.get_metadata() for f in album_tracks]}.pop() 538 539 # if both --add-replay-gain and --remove-replay-gain 540 # are specified, do nothing 541 542 if options.add_replay_gain and not options.remove_replay_gain: 543 if album_number is None: 544 progress_text = _.RG_ADDING_REPLAYGAIN 545 completion_output = _.RG_REPLAYGAIN_ADDED 546 else: 547 progress_text = _.RG_ADDING_REPLAYGAIN_TO_ALBUM % \ 548 (album_number) 549 completion_output = _.RG_REPLAYGAIN_ADDED_TO_ALBUM % \ 550 (album_number) 551 552 queue.execute( 553 function=add_replay_gain, 554 progress_text=progress_text, 555 completion_output=completion_output, 556 tracks=album_tracks) 557 elif options.remove_replay_gain and not options.add_replay_gain: 558 for track in album_tracks: 559 try: 560 track.delete_replay_gain() 561 except IOError as err: 562 msg.error(err) 563 sys.exit(1) 564 msg.output( 565 _.RG_REPLAYGAIN_REMOVED if album_number is None else 566 (_.REPLAYGAIN_REMOVED_FROM_ALBUM % (album_number))) 567 568 # execute ReplayGain addition once all tracks have been tagged 569 try: 570 queue.run(options.max_processes) 571 except (ValueError, IOError) as err: 572 msg.error(err) 573 sys.exit(1) 574 except KeyboardInterrupt: 575 msg.error(_.ERR_CANCELLED) 576 sys.exit(1) 577