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